84 lines
2.8 KiB
Python
84 lines
2.8 KiB
Python
"""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))
|