Files
analytics/app/errors.py
T
JiriUhlir 6934f22253 first
2026-06-18 11:58:23 +02:00

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))