Première exécution réelle de la stack (build des images, PostgreSQL 16, parcours fonctionnels en HTTP) : 28 tables, 105 index, extension pg_trgm, et 181 assertions rejouées après correction. Santé : filtres enum invalides renvoyaient 500 au lieu d'une erreur française ; plan_line s'arrêtait à la fin de la fenêtre du graphique au lieu de la date d'atteinte de l'objectif ; deficit_target_kcal était recalculé après le plancher calorique ; « dernière pesée » affichait deux valeurs différentes selon l'endpoint ; objectif protéines absent. Vape : durée de vie moyenne des résistances incluait la résistance en cours ; archiver la recette active la laissait active ; coût théorique inventé avant la date d'arrêt ; économies projetées dans le futur ; €/ml arrondi à 2 décimales écrasait le modèle de coût DIY. Finances : le sankey compensait crédits et débits non catégorisés ; rows_total excluait les lignes filtrées, faussant l'arithmétique du rapport d'import. Socle : les erreurs HTTP du framework fuitaient en anglais dans l'enveloppe française ; nginx renvoyait sa page 413 HTML au lieu du JSON français ; fins de ligne normalisées en LF. 348 tests pytest (+7), ruff, tsc et vite build au vert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
126 lines
3.8 KiB
Python
126 lines
3.8 KiB
Python
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)),
|
|
)
|