"""Maps application exceptions to the ADR-0008 error envelope. This is the single place that knows the exception-type -> status-code mapping; application/infrastructure code never imports FastAPI or raises `HTTPException` (ADR-0015). """ import structlog from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException from src.application.auth.errors import ( InvalidApiKeyError, MissingScopeError, TenantInactiveError, ) from src.application.files.errors import FileTooLargeError, InvalidUploadError from src.application.ingestion.errors import ( ChunkLimitExceededError, DocumentParseError, EmbedderError, IngestionAtCapacityError, IngestionTimeoutError, UnsupportedSourceTypeError, ) logger = structlog.get_logger(__name__) # A fixed backoff hint, not a computed retry budget: ADR-0017 rejects a # request outright at capacity rather than queueing it, so there is no # in-process estimate of when a slot will free up to report instead. _CAPACITY_RETRY_AFTER_SECONDS = 1 # (exception type, status code, stable error code) _MAPPING: tuple[tuple[type[Exception], int, str], ...] = ( (InvalidApiKeyError, status.HTTP_401_UNAUTHORIZED, "invalid_api_key"), (TenantInactiveError, status.HTTP_401_UNAUTHORIZED, "tenant_not_found"), (MissingScopeError, status.HTTP_403_FORBIDDEN, "missing_scope"), (InvalidUploadError, status.HTTP_400_BAD_REQUEST, "validation_error"), (DocumentParseError, status.HTTP_400_BAD_REQUEST, "validation_error"), (UnsupportedSourceTypeError, status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "unsupported_media_type"), (FileTooLargeError, status.HTTP_413_CONTENT_TOO_LARGE, "payload_too_large"), (ChunkLimitExceededError, status.HTTP_413_CONTENT_TOO_LARGE, "payload_too_large"), (EmbedderError, status.HTTP_502_BAD_GATEWAY, "embedder_error"), (IngestionTimeoutError, status.HTTP_504_GATEWAY_TIMEOUT, "ingestion_timeout"), ) def _request_id(request: Request) -> str | None: return getattr(request.state, "request_id", None) def _envelope( code: str, message: str, request_id: str | None, details: dict[str, object] | None = None ) -> dict[str, object]: return { "error": { "code": code, "message": message, "details": details or {}, "request_id": request_id, } } def register_exception_handlers(app: FastAPI) -> None: for exc_type, status_code, error_code in _MAPPING: def _handler( request: Request, exc: Exception, status_code: int = status_code, error_code: str = error_code, ) -> JSONResponse: return JSONResponse( status_code=status_code, content=_envelope(error_code, str(exc), _request_id(request)), ) app.add_exception_handler(exc_type, _handler) @app.exception_handler(IngestionAtCapacityError) def _capacity_handler(request: Request, exc: IngestionAtCapacityError) -> JSONResponse: return JSONResponse( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=_envelope("ingestion_at_capacity", str(exc), _request_id(request)), headers={"Retry-After": str(_CAPACITY_RETRY_AFTER_SECONDS)}, ) @app.exception_handler(RequestValidationError) def _validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse: return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content=_envelope( "validation_error", "request validation failed", _request_id(request), details={"errors": exc.errors()}, ), ) @app.exception_handler(StarletteHTTPException) def _http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: code = "not_found" if exc.status_code == status.HTTP_404_NOT_FOUND else "http_error" return JSONResponse( status_code=exc.status_code, content=_envelope(code, str(exc.detail), _request_id(request)), ) @app.exception_handler(Exception) def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: logger.exception("api.unhandled_exception", path=request.url.path) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=_envelope( "internal_error", "an unexpected error occurred", _request_id(request) ), )