first
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""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.
|
||||
|
||||
Shape matches the sibling `analytics` service so both proxies fail the same way.
|
||||
"""
|
||||
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 (or is malformed)."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class UpstreamError(Exception):
|
||||
"""The Meta Graph API 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))
|
||||
Reference in New Issue
Block a user