"""S3 connector for checking file existence.""" from collections.abc import Callable from enum import IntEnum from functools import wraps from typing import Optional, ParamSpec, TypeVar import boto3 from botocore.client import BaseClient, Config from botocore.exceptions import ClientError from lambdacommon.common_config import logger from src.errors import S3FileNotFoundError, TransientError P = ParamSpec('P') T = TypeVar('T') class S3ErrorCode(IntEnum): """S3 error codes.""" NO_SUCH_KEY = 404 TOO_MANY_REQUESTS = 429 INTERNAL_ERROR = 500 BAD_GATEWAY = 502 SLOW_DOWN = 503 GATEWAY_TIMEOUT = 504 TRANSIENT_ERROR_CODES: set[S3ErrorCode] = { 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: error_code = e.response['Error']['Code'] http_status = e.response['ResponseMetadata']['HTTPStatusCode'] # Permanent errors if http_status == S3ErrorCode.NO_SUCH_KEY: 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['Error']['Message'] logger.warning(f'S3 transient error ({error_code}): {error_msg}') raise TransientError( f'S3 service error ({error_code}): {error_msg}' ) from e # Other errors logger.error(f'S3 client error: {e}') raise return wrapper class S3Connector: """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 object_exists(self, bucket: str, key: str) -> bool: """Check if an object exists in S3. Args: bucket: S3 bucket name key: S3 object key Returns: True if object exists, False otherwise Raises: TransientError: If S3 throttling or service errors occur ClientError: For other S3 errors """ try: self.s3_client.head_object(Bucket=bucket, Key=key) return True except ClientError as e: http_status = e.response['ResponseMetadata']['HTTPStatusCode'] if http_status == S3ErrorCode.NO_SUCH_KEY: return False raise 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 the recommended signature version for all S3 operations config = Config(signature_version='s3v4') return boto3.client('s3', config=config) def get_s3_connector(s3_client: Optional[BaseClient] = None) -> S3Connector: """Get S3 connector instance. Args: s3_client: Optional Boto3 S3 client instance Returns: Configured S3Connector instance """ if not s3_client: s3_client = get_s3_client() return S3Connector(s3_client=s3_client)