"""S3 connector with automatic error handling and retry logic.""" from collections.abc import Callable from enum import IntEnum from functools import wraps from pathlib import Path from typing import Optional, ParamSpec, TypeVar import boto3 from botocore.client import BaseClient, Config from botocore.exceptions import ClientError from pydantic import BaseModel, ConfigDict, Field from src.errors import S3FileNotFoundError, TransientError from src.infra.log import logger P = ParamSpec('P') T = TypeVar('T') class S3ErrorCode(IntEnum): """S3 HTTP status codes.""" NOT_FOUND = 404 TOO_MANY_REQUESTS = 429 INTERNAL_ERROR = 500 BAD_GATEWAY = 502 SLOW_DOWN = 503 GATEWAY_TIMEOUT = 504 class S3FileMetadata(BaseModel): """S3 file metadata model. Represents metadata about a file stored in S3, including its location, size, and checksum information. """ model_config = ConfigDict(extra='ignore') bucket: str = Field(..., description='Bucket name') custom_metadata: dict[str, str] = Field( default_factory=dict, description='Custom metadata from x-amz-meta-* headers' ) key: str = Field(..., description='Object key') size: int = Field(..., description='The file size') etag: str = Field(..., description='ETag (MD5 for single-part uploads)') # Use frozenset for immutability TRANSIENT_ERROR_CODES: frozenset[int] = frozenset( { S3ErrorCode.TOO_MANY_REQUESTS, S3ErrorCode.INTERNAL_ERROR, S3ErrorCode.BAD_GATEWAY, S3ErrorCode.SLOW_DOWN, S3ErrorCode.GATEWAY_TIMEOUT, } ) def handle_s3_errors(func: Callable[P, T]) -> Callable[P, T]: """Decorate to handle S3 errors. Catches ClientError and maps to appropriate custom exceptions. """ @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: try: return func(*args, **kwargs) except ClientError as e: # Botocore ensures 'ResponseMetadata' always exists meta = e.response.get('ResponseMetadata', {}) http_status = meta.get('HTTPStatusCode') error_code = e.response.get('Error', {}).get('Code', 'Unknown') # Permanent errors (e.g. failing to download a missing file) if http_status == S3ErrorCode.NOT_FOUND: logger.error(f'S3 object not found: {error_code}') raise S3FileNotFoundError(f'S3 object not found: {error_code}') from e # Retriable throttling and service errors if http_status in TRANSIENT_ERROR_CODES: error_msg = e.response.get('Error', {}).get('Message', 'Unknown Error') logger.warning(f'S3 transient error ({error_code}): {error_msg}') raise TransientError( f'S3 service error ({error_code}): {error_msg}' ) from e # Other errors (403 Forbidden, 400 Bad Request, etc.) logger.error(f'S3 client error: {e}') raise return wrapper class S3Connection: """Connector for S3 operations.""" def __init__(self, s3_client: BaseClient) -> None: """Initialize S3 connector. Args: s3_client: Boto3 S3 client instance """ self.s3_client = s3_client @handle_s3_errors def download_file(self, bucket: str, key: str, file_path: str | Path) -> None: """Download file from S3. Args: bucket: S3 bucket name key: S3 object key file_path: Local path to save the file Raises: S3FileNotFoundError: If the file does not exist. """ self.s3_client.download_file(Bucket=bucket, Key=key, Filename=file_path) @handle_s3_errors def get_file_metadata(self, bucket: str, key: str) -> S3FileMetadata | None: """Get file metadata from S3 without downloading. Args: bucket: S3 bucket name key: S3 object key Returns: S3FileMetadata | None: S3 file metadata if file exists, otherwise None """ try: response = self.s3_client.head_object(Bucket=bucket, Key=key) except ClientError as e: http_status = e.response.get('ResponseMetadata', {}).get('HTTPStatusCode') if http_status == S3ErrorCode.NOT_FOUND: return None raise return S3FileMetadata( bucket=bucket, key=key, size=response['ContentLength'], # Remove double and single quotes etag=response['ETag'].strip('"').strip("'"), # Custom 'x-amz-meta-*' headers custom_metadata=response.get('Metadata', {}), ) @handle_s3_errors def upload_file(self, file_path: str | Path, bucket: str, key: str) -> None: """Upload file to S3. Args: file_path: Local path of the file to upload bucket: S3 bucket name key: S3 object key """ self.s3_client.upload_file(Filename=file_path, Bucket=bucket, Key=key) def get_s3_client(config: Optional[Config] = None) -> BaseClient: """Get s3 client. Args: config: Optional botocore Config for the client Returns: Configured Boto3 S3 client instance """ if config is None: # SigV4 is best practice and required for some regions config = Config(signature_version='s3v4') return boto3.client('s3', config=config) def get_s3_connection(s3_client: Optional[BaseClient] = None) -> S3Connection: """Get S3 connector instance. Args: s3_client: Optional Boto3 S3 client instance Returns: Configured S3Connection instance """ if not s3_client: s3_client = get_s3_client() return S3Connection(s3_client=s3_client)