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 maltenu
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é
422pour un défaut de schéma (Pydantic),400pour une règle sémantique métier violée ; ne jamais confondre les deux.500pour un défaut du service,503pour 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_idet renvoie un message court sans trace Python. debug=Falseen production,Idempotency-Keysur les routes qui stockent,Retry-Afteravec 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.