""" 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