This commit is contained in:
JiriUhlir
2026-06-18 11:58:23 +02:00
parent 284e013753
commit 6934f22253
18 changed files with 1367 additions and 17 deletions
+83
View File
@@ -0,0 +1,83 @@
"""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))