import http from typing import Any from fastapi import FastAPI, Request from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException class AppError(Exception): status_code: int = 400 code: str = "bad_request" def __init__(self, message: str, *, details: dict[str, Any] | None = None): self.message = message self.details = details or {} super().__init__(message) class NotFoundError(AppError): status_code, code = 404, "not_found" class ConflictError(AppError): status_code, code = 409, "conflict" class UnauthorizedError(AppError): status_code, code = 401, "unauthorized" class ForbiddenError(AppError): status_code, code = 403, "forbidden" class DomainValidationError(AppError): status_code, code = 422, "validation_error" class PayloadTooLargeError(AppError): status_code, code = 413, "payload_too_large" _HTTP_CODES = { 400: "bad_request", 401: "unauthorized", 403: "forbidden", 404: "not_found", 405: "method_not_allowed", 409: "conflict", 413: "payload_too_large", 422: "validation_error", 500: "internal_error", } # Starlette fills `HTTPException.detail` with the ENGLISH reason phrase of the # status code ("Not Found", "Method Not Allowed"…). C1 forbids shipping those to # the user, so framework-raised errors get a French message instead. _FRENCH_MESSAGES = { 400: "Requête invalide.", 401: "Authentification requise.", 403: "Accès refusé.", 404: "Ressource introuvable.", 405: "Méthode non autorisée pour cette ressource.", 409: "Conflit avec l'état actuel de la ressource.", 413: "Le contenu envoyé est trop volumineux.", 422: "Les données envoyées sont invalides.", 429: "Trop de requêtes. Réessayez dans un instant.", 500: "Une erreur interne est survenue.", } _FALLBACK_MESSAGE = "Une erreur est survenue." def _english_default(status_code: int) -> str | None: try: return http.HTTPStatus(status_code).phrase except ValueError: return None def _french_message(exc: StarletteHTTPException) -> str: detail = exc.detail if isinstance(exc.detail, str) else None if detail and detail != _english_default(exc.status_code): return detail # explicit message set by the application: keep it return _FRENCH_MESSAGES.get(exc.status_code, _FALLBACK_MESSAGE) def _payload(code: str, message: str, details: Any = None) -> dict[str, Any]: return {"error": {"code": code, "message": message, "details": details or {}}} def register_error_handlers(app: FastAPI) -> None: @app.exception_handler(AppError) async def app_error_handler(_: Request, exc: AppError) -> JSONResponse: return JSONResponse( status_code=exc.status_code, content=_payload(exc.code, exc.message, exc.details), ) @app.exception_handler(RequestValidationError) async def validation_handler( _: Request, exc: RequestValidationError ) -> JSONResponse: return JSONResponse( status_code=422, content=_payload( "validation_error", "Les données envoyées sont invalides.", {"errors": jsonable_encoder(exc.errors())}, ), ) @app.exception_handler(StarletteHTTPException) async def http_exception_handler( _: Request, exc: StarletteHTTPException ) -> JSONResponse: # Normalize framework-raised HTTP errors (404 route, 405…) to the # single error shape of §8.3. code = _HTTP_CODES.get(exc.status_code, "error") return JSONResponse( status_code=exc.status_code, content=_payload(code, _french_message(exc)), )