"""One shape for every failure the API reports. FastAPI's default is two shapes: `{"detail": "Quiz not found"}` for a raised HTTPException and `{"detail": [ {...}, {...} ]}` for a validation failure. A browser can squint at both — this repository's frontend has a helper that does exactly that — but a client written against the API has to guess which it got, and a `detail` that is sometimes a sentence and sometimes a list of objects is not a contract anybody can code against. So every error carries an `error` object as well: a stable machine-readable code, one human sentence, and — for a validation failure — which fields were wrong and why. `detail` is left exactly as it was, because removing it would break every call site in the web app for no benefit to anyone. """ from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException #: Status code to code word. A client switches on the word, not the number: #: the number says what happened to the request, the word says what happened. CODES = { status.HTTP_400_BAD_REQUEST: "bad_request", status.HTTP_401_UNAUTHORIZED: "unauthenticated", status.HTTP_402_PAYMENT_REQUIRED: "payment_required", status.HTTP_403_FORBIDDEN: "forbidden", status.HTTP_404_NOT_FOUND: "not_found", status.HTTP_405_METHOD_NOT_ALLOWED: "method_not_allowed", status.HTTP_409_CONFLICT: "conflict", status.HTTP_413_REQUEST_ENTITY_TOO_LARGE: "too_large", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE: "unsupported_media_type", status.HTTP_422_UNPROCESSABLE_ENTITY: "invalid_request", status.HTTP_429_TOO_MANY_REQUESTS: "rate_limited", status.HTTP_500_INTERNAL_SERVER_ERROR: "server_error", status.HTTP_502_BAD_GATEWAY: "upstream_failed", status.HTTP_503_SERVICE_UNAVAILABLE: "unavailable", } def code_for(status_code: int) -> str: if status_code in CODES: return CODES[status_code] return "client_error" if status_code < 500 else "server_error" def _sentence(detail) -> str: """One line a person could be shown, whatever the detail turned out to be.""" if isinstance(detail, str): return detail if isinstance(detail, list) and detail: first = detail[0] if isinstance(first, dict) and first.get("msg"): where = ".".join(str(part) for part in first.get("loc", []) if part != "body") return f"{where}: {first['msg']}" if where else str(first["msg"]) return "Request failed" def _fields(errors) -> list[dict]: """Which inputs were wrong, in the caller's own terms.""" out = [] for error in errors or []: location = [str(part) for part in error.get("loc", [])] # "body" is where it came from, not what was wrong with it. name = ".".join(part for part in location[1:] or location) out.append({"field": name, "message": error.get("msg", "Invalid value"), "type": error.get("type", "invalid")}) return out def envelope(status_code: int, message: str, *, code: str | None = None, fields: list[dict] | None = None, detail=None) -> dict: body = {"detail": detail if detail is not None else message, "error": {"code": code or code_for(status_code), "message": message}} if fields: body["error"]["fields"] = fields return body def install(app: FastAPI) -> None: @app.exception_handler(StarletteHTTPException) async def http_error(request: Request, exc: StarletteHTTPException): return JSONResponse( status_code=exc.status_code, content=envelope(exc.status_code, _sentence(exc.detail), detail=exc.detail), headers=getattr(exc, "headers", None), ) @app.exception_handler(RequestValidationError) async def validation_error(request: Request, exc: RequestValidationError): errors = exc.errors() # Pydantic puts an unserialisable exception object in `ctx` for some # error types; the default handler drops it and so must this one, or # reporting the error becomes its own 500. clean = [{k: v for k, v in error.items() if k != "ctx"} for error in errors] return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content=envelope(422, _sentence(clean), code="invalid_request", fields=_fields(clean), detail=clean), )