Files
lifetrack/apps/api/app/core/errors.py
T
MeeJayandClaude Opus 5 fa3db7ff22 Fix 20+ defects found by real Docker/PostgreSQL deployment
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>
2026-08-14 11:40:05 +02:00

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)),
)