File size: 2,724 Bytes
8c1b9fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
"""Structured error handling for the API.

Every error response — AuralynqError, a plain FastAPI/Starlette
HTTPException (404s, the auth middleware's 401), a 422 request-validation
error, or an unhandled exception — uses the same envelope so a client only
ever parses one shape:

    {"error": {"code": "...", "message": "...", "details": {...}, "trace_id": "..."}}

`trace_id` is the same per-request ID already exposed via the `X-Request-ID`
response header (see the request_id middleware in auralynq/serving/app.py).
"""

from __future__ import annotations

from typing import Any

from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException


def _trace_id(request: Request) -> str:
    return getattr(request.state, "request_id", "")


def _envelope(code: str, message: str, details: dict[str, Any], trace_id: str) -> dict[str, Any]:
    return {"error": {"code": code, "message": message, "details": details, "trace_id": trace_id}}


class AuralynqError(Exception):
    """Raise with a short machine-readable ``code`` and a human ``detail``."""

    def __init__(
        self,
        code: str,
        *,
        status_code: int = 400,
        detail: str = "",
        details: dict[str, Any] | None = None,
    ) -> None:
        super().__init__(code)
        self.code = code
        self.status_code = status_code
        self.message = detail or code
        self.details = details or {}


async def auralynq_error_handler(request: Request, exc: AuralynqError) -> JSONResponse:
    return JSONResponse(
        status_code=exc.status_code,
        content=_envelope(exc.code, exc.message, exc.details, _trace_id(request)),
    )


async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
    """Covers plain `raise HTTPException(...)` calls and framework 404s/405s."""
    return JSONResponse(
        status_code=exc.status_code,
        content=_envelope("http_error", str(exc.detail), {}, _trace_id(request)),
        headers=getattr(exc, "headers", None),
    )


async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
    return JSONResponse(
        status_code=422,
        content=_envelope(
            "validation_error",
            "Request validation failed",
            {"errors": exc.errors()},
            _trace_id(request),
        ),
    )


async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
    return JSONResponse(
        status_code=500,
        content=_envelope("internal_error", str(exc), {}, _trace_id(request)),
    )