"""Domain exceptions and FastAPI exception handlers. All errors are surfaced as JSON (never swallowed). Upstream failures preserve the upstream status code and body so callers can diagnose problems. """ from __future__ import annotations from typing import Any from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from .logging_config import get_logger logger = get_logger(__name__) class MissingCredentialsError(Exception): """A required credential header was not supplied.""" def __init__(self, message: str) -> None: super().__init__(message) self.message = message class UpstreamError(Exception): """The upstream API (Google / Sklik) returned an error or was unreachable. ``status`` is the HTTP status to return to the caller. ``upstream_status`` and ``body`` carry the upstream detail when available. """ def __init__( self, message: str, *, status: int = 502, upstream_status: int | None = None, body: Any = None, ) -> None: super().__init__(message) self.message = message self.status = status self.upstream_status = upstream_status self.body = body def _problem(status: int, title: str, **extra: Any) -> JSONResponse: payload: dict[str, Any] = {"error": title, "status": status} payload.update({k: v for k, v in extra.items() if v is not None}) return JSONResponse(status_code=status, content=payload) def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(MissingCredentialsError) async def _missing_credentials(request: Request, exc: MissingCredentialsError): # Not an error worth a stack trace, but we still log it so missing-header # problems are diagnosable. The credential VALUE is never logged. logger.warning("Missing credentials for %s: %s", request.url.path, exc.message) return _problem(401, "missing_credentials", detail=exc.message) @app.exception_handler(UpstreamError) async def _upstream_error(request: Request, exc: UpstreamError): logger.error( "Upstream error on %s: %s (upstream_status=%s)", request.url.path, exc.message, exc.upstream_status, ) return _problem( exc.status, "upstream_error", detail=exc.message, upstream_status=exc.upstream_status, upstream_body=exc.body, ) @app.exception_handler(Exception) async def _unhandled(request: Request, exc: Exception): # Last-resort handler: never leak a stack trace to the client, but always # log it server-side so nothing fails silently. logger.exception("Unhandled error on %s", request.url.path) return _problem(500, "internal_error", detail=str(exc))