Files
nfoguard/utils/error_handler.py
T
sbcrumb fa1e4e2ec7
Build & Push DEV to DockerHub / docker (push) Failing after 1m12s
improvements (#15)
Reviewed-on: #15
2025-09-27 13:00:08 -04:00

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