"""S3 connector with error handling decorator.""" from __future__ import annotations from collections.abc import Callable from enum import IntEnum from functools import wraps from typing import 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 HTTP status codes mapped to error categories.""" FORBIDDEN = 403 NOT_FOUND = 404 INTERNAL_ERROR = 500 BAD_GATEWAY = 502 SLOW_DOWN = 503 GATEWAY_TIMEOUT = 504 TRANSIENT_ERROR_CODES: set[int] = { S3ErrorCode.INTERNAL_ERROR, S3ErrorCode.BAD_GATEWAY, S3ErrorCode.SLOW_DOWN, S3ErrorCode.GATEWAY_TIMEOUT, } PERMANENT_NOT_FOUND_CODES: set[int] = { S3ErrorCode.FORBIDDEN, S3ErrorCode.NOT_FOUND, } 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'] if http_status in PERMANENT_NOT_FOUND_CODES: logger.error('S3 object not found: %s', error_code) raise S3FileNotFoundError(f'S3 object not found: {error_code}') from e if http_status in TRANSIENT_ERROR_CODES: error_msg = e.response['Error']['Message'] logger.warning('S3 transient error (%s): %s', error_code, error_msg) raise TransientError( f'S3 service error ({error_code}): {error_msg}' ) from e logger.error('S3 client error: %s', e) raise return wrapper class S3Connector: """Connector for S3 download and upload operations.""" def __init__(self, s3_client: BaseClient) -> None: """Initialize S3 connector.""" self.s3_client = s3_client @handle_s3_errors def download_object(self, bucket: str, key: str) -> bytes: """Download S3 object and return contents as bytes.""" response = self.s3_client.get_object(Bucket=bucket, Key=key) return response['Body'].read() @handle_s3_errors def upload_buffer(self, bucket: str, key: str, data: bytes) -> None: """Upload bytes to S3 at the given key.""" self.s3_client.put_object( Bucket=bucket, Key=key, Body=data, ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ) def get_s3_client(config: Config | None = None) -> BaseClient: """Get S3 client with SigV4.""" if config is None: config = Config(signature_version='s3v4') return boto3.client('s3', config=config) def get_s3_connector(s3_client: BaseClient | None = None) -> S3Connector: """Get S3 connector instance.""" if not s3_client: s3_client = get_s3_client() return S3Connector(s3_client=s3_client)