@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Error handling utilities for NFOGuard
|
||||
Provides structured error handling, retry mechanisms, and error reporting
|
||||
"""
|
||||
import time
|
||||
import functools
|
||||
from typing import Callable, Optional, Type, Union, List, Any
|
||||
from pathlib import Path
|
||||
|
||||
from utils.logging import _log
|
||||
from utils.exceptions import (
|
||||
NFOGuardException,
|
||||
RetryableError,
|
||||
NetworkRetryableError,
|
||||
TemporaryFileError,
|
||||
ExternalAPIError,
|
||||
FileOperationError
|
||||
)
|
||||
|
||||
|
||||
def with_error_handling(
|
||||
operation_name: str,
|
||||
log_errors: bool = True,
|
||||
reraise: bool = True,
|
||||
fallback_value: Any = None
|
||||
):
|
||||
"""
|
||||
Decorator for standardized error handling
|
||||
|
||||
Args:
|
||||
operation_name: Name of the operation for logging
|
||||
log_errors: Whether to log errors automatically
|
||||
reraise: Whether to reraise exceptions after logging
|
||||
fallback_value: Value to return if error occurs and reraise=False
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except NFOGuardException as e:
|
||||
if log_errors:
|
||||
_log("ERROR", f"{operation_name} failed: {e.message}")
|
||||
if e.details:
|
||||
_log("DEBUG", f"{operation_name} error details: {e.details}")
|
||||
if reraise:
|
||||
raise
|
||||
return fallback_value
|
||||
except Exception as e:
|
||||
if log_errors:
|
||||
_log("ERROR", f"{operation_name} failed with unexpected error: {e}")
|
||||
if reraise:
|
||||
# Wrap unexpected errors in our custom exception
|
||||
raise NFOGuardException(
|
||||
f"{operation_name} failed: {str(e)}",
|
||||
{"original_error": str(e), "error_type": type(e).__name__}
|
||||
)
|
||||
return fallback_value
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def with_retry(
|
||||
max_attempts: int = 3,
|
||||
delay: float = 1.0,
|
||||
backoff_factor: float = 2.0,
|
||||
retry_on: Union[Type[Exception], List[Type[Exception]]] = None
|
||||
):
|
||||
"""
|
||||
Decorator for retry logic on retryable errors
|
||||
|
||||
Args:
|
||||
max_attempts: Maximum number of retry attempts
|
||||
delay: Initial delay between retries in seconds
|
||||
backoff_factor: Factor to multiply delay by after each attempt
|
||||
retry_on: Exception types to retry on (defaults to RetryableError)
|
||||
"""
|
||||
if retry_on is None:
|
||||
retry_on = [RetryableError]
|
||||
elif not isinstance(retry_on, list):
|
||||
retry_on = [retry_on]
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
current_delay = delay
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
|
||||
# Check if this is a retryable error
|
||||
should_retry = any(isinstance(e, exc_type) for exc_type in retry_on)
|
||||
|
||||
if not should_retry or attempt == max_attempts - 1:
|
||||
# Don't retry or max attempts reached
|
||||
raise
|
||||
|
||||
# Use custom retry delay if available
|
||||
retry_delay = current_delay
|
||||
if isinstance(e, RetryableError) and e.retry_after:
|
||||
retry_delay = e.retry_after
|
||||
|
||||
_log("WARNING", f"Attempt {attempt + 1}/{max_attempts} failed: {e}. Retrying in {retry_delay}s...")
|
||||
time.sleep(retry_delay)
|
||||
current_delay *= backoff_factor
|
||||
|
||||
# This should never be reached, but just in case
|
||||
raise last_exception
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def safe_file_operation(
|
||||
operation: str,
|
||||
file_path: Union[str, Path],
|
||||
operation_func: Callable,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Safely perform file operations with error handling
|
||||
|
||||
Args:
|
||||
operation: Description of the operation
|
||||
file_path: Path to the file being operated on
|
||||
operation_func: Function to execute
|
||||
*args, **kwargs: Arguments to pass to operation_func
|
||||
|
||||
Returns:
|
||||
Result of operation_func or None if error
|
||||
|
||||
Raises:
|
||||
FileOperationError: If file operation fails
|
||||
"""
|
||||
try:
|
||||
return operation_func(*args, **kwargs)
|
||||
except PermissionError as e:
|
||||
raise FileOperationError(operation, str(file_path), f"Permission denied: {e}")
|
||||
except FileNotFoundError as e:
|
||||
raise FileOperationError(operation, str(file_path), f"File not found: {e}")
|
||||
except OSError as e:
|
||||
# Check if this might be a temporary error
|
||||
if e.errno in [28, 122]: # No space left, quota exceeded
|
||||
raise TemporaryFileError(str(file_path), operation, f"Disk space issue: {e}")
|
||||
raise FileOperationError(operation, str(file_path), f"OS error: {e}")
|
||||
except Exception as e:
|
||||
raise FileOperationError(operation, str(file_path), f"Unexpected error: {e}")
|
||||
|
||||
|
||||
def safe_api_call(
|
||||
api_name: str,
|
||||
operation: str,
|
||||
api_func: Callable,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Safely perform API calls with error handling
|
||||
|
||||
Args:
|
||||
api_name: Name of the API (e.g., "Sonarr", "TMDB")
|
||||
operation: Description of the operation
|
||||
api_func: Function to execute
|
||||
*args, **kwargs: Arguments to pass to api_func
|
||||
|
||||
Returns:
|
||||
Result of api_func
|
||||
|
||||
Raises:
|
||||
ExternalAPIError: If API call fails
|
||||
NetworkRetryableError: If network error that can be retried
|
||||
"""
|
||||
try:
|
||||
return api_func(*args, **kwargs)
|
||||
except ConnectionError as e:
|
||||
raise NetworkRetryableError(f"{api_name} API", f"Connection error: {e}")
|
||||
except TimeoutError as e:
|
||||
raise NetworkRetryableError(f"{api_name} API", f"Timeout error: {e}")
|
||||
except Exception as e:
|
||||
# Check if it's an HTTP error with status code
|
||||
status_code = getattr(e, 'status_code', None) or getattr(e, 'response', {}).get('status_code')
|
||||
response_text = getattr(e, 'text', None) or str(e)
|
||||
|
||||
# Retry on certain HTTP status codes
|
||||
if status_code in [429, 502, 503, 504]: # Rate limit, bad gateway, service unavailable, gateway timeout
|
||||
raise NetworkRetryableError(f"{api_name} API", f"HTTP {status_code}: {response_text}")
|
||||
|
||||
raise ExternalAPIError(api_name, operation, status_code, response_text)
|
||||
|
||||
|
||||
def log_structured_error(error: NFOGuardException, context: Optional[str] = None) -> None:
|
||||
"""
|
||||
Log structured error information
|
||||
|
||||
Args:
|
||||
error: NFOGuardException to log
|
||||
context: Additional context for the error
|
||||
"""
|
||||
error_dict = error.to_dict()
|
||||
if context:
|
||||
error_dict['context'] = context
|
||||
|
||||
_log("ERROR", f"Structured error: {error.message}")
|
||||
_log("DEBUG", f"Error details: {error_dict}")
|
||||
|
||||
|
||||
def create_error_response(error: NFOGuardException, include_details: bool = False) -> dict:
|
||||
"""
|
||||
Create standardized error response for API endpoints
|
||||
|
||||
Args:
|
||||
error: NFOGuardException to convert
|
||||
include_details: Whether to include detailed error information
|
||||
|
||||
Returns:
|
||||
Dictionary suitable for JSON response
|
||||
"""
|
||||
response = {
|
||||
"status": "error",
|
||||
"error_type": error.__class__.__name__,
|
||||
"message": error.message
|
||||
}
|
||||
|
||||
if include_details and error.details:
|
||||
response["details"] = error.details
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def validate_required_config(config_dict: dict, required_keys: List[str]) -> None:
|
||||
"""
|
||||
Validate that required configuration keys are present and not empty
|
||||
|
||||
Args:
|
||||
config_dict: Configuration dictionary to validate
|
||||
required_keys: List of required configuration keys
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If required configuration is missing or invalid
|
||||
"""
|
||||
from utils.exceptions import ConfigurationError
|
||||
|
||||
missing_keys = []
|
||||
empty_keys = []
|
||||
|
||||
for key in required_keys:
|
||||
if key not in config_dict:
|
||||
missing_keys.append(key)
|
||||
elif not config_dict[key]:
|
||||
empty_keys.append(key)
|
||||
|
||||
if missing_keys:
|
||||
raise ConfigurationError(
|
||||
"missing_required_config",
|
||||
f"Missing required configuration keys: {missing_keys}",
|
||||
{"missing_keys": missing_keys}
|
||||
)
|
||||
|
||||
if empty_keys:
|
||||
raise ConfigurationError(
|
||||
"empty_required_config",
|
||||
f"Required configuration keys are empty: {empty_keys}",
|
||||
{"empty_keys": empty_keys}
|
||||
)
|
||||
|
||||
|
||||
class ErrorContext:
|
||||
"""Context manager for adding context to errors"""
|
||||
|
||||
def __init__(self, context: str):
|
||||
self.context = context
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if exc_type and issubclass(exc_type, NFOGuardException):
|
||||
exc_val.details = exc_val.details or {}
|
||||
exc_val.details['error_context'] = self.context
|
||||
return False # Don't suppress the exception
|
||||
Reference in New Issue
Block a user