284 lines
9.2 KiB
Python
284 lines
9.2 KiB
Python
"""
|
|
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 |