Aller au contenu principal

Module 5 — Gestion des erreurs et codes de retour

Un service qui répond 200 OK à tout ce qu'on lui envoie n'est pas tolérant, il est cassé. Un service qui répond 500 Internal Server Error dès qu'un champ est mal orthographié est agressif et coûteux à intégrer. Entre les deux, il y a une classification fine des erreurs, adossée au protocole HTTP, qui laisse chaque partie faire son travail.

La classification en quatre familles

Les codes HTTP à trois chiffres se rangent en cinq familles ; deux comptent pour un service de scoring.

4xx — la faute est du côté de l'appelant. L'appelant doit corriger sa requête, il ne sert à rien de la rejouer telle quelle. Les trois codes utiles ici sont 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 413 Payload Too Large, 422 Unprocessable Entity et 429 Too Many Requests.

5xx — la faute est du côté du service. L'appelant peut rejouer, avec attente et progression exponentielle. Les deux codes utiles sont 500 Internal Server Error et 503 Service Unavailable.

Un service de scoring qui distingue proprement ces deux familles rend l'appelant capable de décider s'il faut demander à l'utilisateur de corriger, ou s'il faut réessayer dans dix secondes. Un service qui les mélange oblige l'appelant à écrire une logique de reprise qui aggrave le problème.

400 contre 422 : la nuance qui compte

C'est la confusion la plus fréquente. FastAPI utilise 422 (« Entity non-traitable ») pour ce qui échoue à la validation Pydantic : champ manquant, type incompatible, valeur hors des bornes déclarées. Le corps de la requête ne correspond pas au schéma.

400 Bad Request est réservé à ce qui passe le schéma mais viole une règle sémantique que Pydantic n'a pas encodée. Deux exemples sur le fil rouge : « le champ date_fin_contrat est antérieur à date_debut_contrat » et « le forfait entreprise exige un revenu_moyen supérieur à 100 EUR ». On peut les encoder en Pydantic (via model_validator, module 2), ils tomberont alors en 422 ; ou les vérifier dans la route et lever HTTPException(400). Le choix n'est pas purement technique : les règles métier qu'on veut voir apparaître dans le schéma public vont dans Pydantic, celles qui dépendent d'un contexte externe (disponibilité d'un service tiers, cohérence base de données) restent dans la route.

from fastapi import HTTPException

@app.post("/predict")
def predict(dossier: DossierAbonne):
if dossier.forfait == "entreprise" and dossier.age < 18:
raise HTTPException(status_code=400, detail="entreprise interdite aux mineurs")
# ...

Ne jamais utiliser 400 pour un champ manquant : c'est le rôle du 422 que FastAPI produit automatiquement.

401, 403, 404, 429 : les autres 4xx utiles

401 Unauthorized signifie « votre appel n'est pas authentifié » (jeton absent ou invalide, module 7). Le nom est trompeur : il désigne bien un défaut d'authentification, pas d'autorisation.

403 Forbidden signifie « vous êtes authentifié, mais vous n'avez pas le droit ». Un jeton valide qui appelle une route réservée aux administrateurs tombe en 403.

404 Not Found est réservé aux ressources : un GET /abonnes/999999 sur un abonné inexistant tombe en 404. On ne l'utilise pas pour une route qui existe et qu'on refuse d'exécuter, cas où on préfère 400 ou 422.

429 Too Many Requests est le code de la limitation de débit (module 7). Le service ajoute alors un en-tête Retry-After en secondes qui indique à l'appelant quand rejouer.

500 contre 503 : distinguer l'erreur du surcroît

500 Internal Server Error couvre les défauts du service lui-même : une KeyError sur une variable oubliée, un modèle qui lève, un fichier inattendu. 503 Service Unavailable couvre l'indisponibilité temporaire : service en cours de redémarrage, dépendance externe injoignable, mode maintenance activé.

La distinction change la stratégie de reprise côté appelant : sur 500, il faut sortir un ticket ; sur 503, il faut réessayer avec une attente qui double à chaque tentative. Un service qui répond 500 pendant un redémarrage impose à ses appelants d'écrire des reprises inutiles et d'ignorer les vrais bugs.

Un gestionnaire d'exceptions pour toute la maison

FastAPI permet d'attraper toute exception non gérée et de la transformer en réponse propre. C'est la brique qui empêche une trace Python de fuir vers l'appelant.

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import logging
import uuid

logger = logging.getLogger("api.erreur")

@app.exception_handler(Exception)
async def toute_exception(request: Request, exc: Exception):
trace_id = str(uuid.uuid4())
logger.exception("erreur non gérée trace_id=%s path=%s", trace_id, request.url.path)
return JSONResponse(
status_code=500,
content={
"detail": "erreur interne, l'incident a été enregistré",
"trace_id": trace_id,
},
)

L'appelant reçoit un message court et un identifiant de trace qu'il peut citer dans un ticket. Le journal serveur, lui, contient la trace complète associée au même trace_id. On peut retrouver l'incident en une recherche sans exposer aux appelants la structure interne du service.

Personnaliser aussi le 422

FastAPI construit un 422 lisible par défaut, mais on peut le remplacer pour qu'il ait la même forme que les autres erreurs.

from fastapi.exceptions import RequestValidationError

@app.exception_handler(RequestValidationError)
async def erreur_de_validation(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"detail": "requête invalide",
"erreurs": [
{"champ": ".".join(str(x) for x in e["loc"]), "message": e["msg"]}
for e in exc.errors()
],
},
)

L'appelant reçoit une liste homogène, plus facile à afficher côté interface utilisateur qu'un JSON brut.

Ne jamais exposer la trace

C'est une règle absolue : la trace Python ne sort jamais dans la réponse HTTP. Elle contient les noms de fichiers internes, parfois des chemins d'installation, parfois même des extraits du code, et donne à un attaquant une carte du service. FastAPI en mode debug=True peut inclure une trace ; ce mode reste local. En production, on désactive explicitement.

app = FastAPI(debug=False)  # explicite, jamais True en production

Le gestionnaire d'exceptions générique ci-dessus produit un message court et un trace_id. C'est le maximum autorisé. Toutes les informations sensibles restent dans les journaux serveur, dont l'accès est contrôlé.

Idempotence et rejeu

Les routes GET sont idempotentes par définition : le même appel donne le même résultat, on peut le rejouer sans risque. Les routes POST de prédiction sont implicitement idempotentes tant que le modèle et les données ne changent pas : rejouer POST /predict sur le même DossierAbonne donne la même probabilité (sauf changement de version du modèle entre deux appels). Un 429 ou un 503 sur une route de prédiction peut donc être rejoué sans conséquence, ce qui est important pour les appelants qui écrivent leur logique de reprise.

En revanche, une route qui stocke une prédiction (« /score-et-notifie ») n'est pas idempotente : deux appels avec le même corps peuvent créer deux notifications. On demande alors à l'appelant un identifiant d'idempotence dans un en-tête Idempotency-Key, et le service refuse la seconde tentative avec 409 Conflict.

400 pour un champ manquant est un signal d'un service mal

tenu

Voir un service renvoyer 400 pour « champ age manquant » est le signe que la classification 4xx a été confondue. C'est la première chose à regarder quand on reprend un service ancien : passer au 422 systématique via Pydantic, réserver 400 aux règles sémantiques dépendantes d'un contexte externe. La discipline paie : les appelants écrivent immédiatement le bon switch sur le code retour.

En résumé

  • 422 pour un défaut de schéma (Pydantic), 400 pour une règle sémantique métier violée ; ne jamais confondre les deux.
  • 500 pour un défaut du service, 503 pour une indisponibilité temporaire ; la stratégie de reprise de l'appelant en dépend.
  • Un gestionnaire d'exceptions global attrape tout, journalise avec un trace_id et renvoie un message court sans trace Python.
  • debug=False en production, Idempotency-Key sur les routes qui stockent, Retry-After avec 429 et 503.

Le module 6 ouvre le sujet asynchrone : quand async def aide, quand il gêne, et comment lancer une tâche de fond pour le scoring d'un fichier.