Validation et réponses d'erreur
- Leçon 8
- 45 min
- Module 3 : Concevoir un contrat utilisable
Leçon 8 sur 21 · Créer et déployer votre première APIValidation et réponses d'erreur
1. Comprendre la requête Web
2. Créer un petit service FastAPI
3. Concevoir un contrat utilisable
4. Documenter l'API avec OpenAPI et Swagger UI
5. Explorer et tester avec Postman
6. Tester et renforcer le service
7. Déployer et vérifier
Ce que vous saurez faire
Donnez à chaque erreur la même structure. Le client pourra ainsi toutes les traiter avec le même code. Assurez-vous aussi qu’un bogue dans votre API affiche au client un message court et sûr. Les détails restent dans vos journaux.
Avant de commencerCe qu’il faut savoir et avoir sous la main
Ce que vous devez savoir
- Les exceptions et
except, dans Exceptions et journalisation : try et except. - Le corps standard
422de FastAPI et son champloc, dans Modèles de requête et de réponse : requêtes valides et invalides. - Le tableau du contrat, dans Conception des ressources et des points de terminaison : règle 4 : écrire le contrat sous forme de tableau.
Ce qu’il vous faut sur votre ordinateur
- L’API avec
/v1/classifyet/v1/categories, dans Conception des ressources et des points de terminaison : exercice guidé : ajouter une version à l’API. Si/v1/categoriesmanque, refaites cet exercice guidé. - Les fichiers
billing.json,empty-subject.jsonetwrong-types.jsondansrequests, dans Modèles de requête et de réponse : exercice guidé : créer et appeler le point de terminaison. Vous ajoutez deux autres fichiers dans cette leçon. ticket_api/classifier.py, dans Séparer les responsabilités : exemple détaillé : le module du classificateur. Vous y ajoutez volontairement un bogue, puis vous le retirez.- Le serveur de développement fonctionne, comme dans Environnement et premier point de terminaison : étape 3 : démarrer le serveur.
- Si votre projet manque ou ne fonctionne plus, copiez l’instantané
end-of-m02, le projet à la fin du module 2, comme dans Utiliser un instantané. Refaites ensuite Conception des ressources et des points de terminaison : exercice guidé : ajouter une version à l’API.
Testé avec Python 3.14.6 et FastAPI 0.142.2 sur macOS.
Trois structures, c’est deux de trop
Section intitulée « Trois structures, c’est deux de trop »Actuellement, l’API répond aux erreurs de trois façons différentes :
| Erreur | Corps actuel |
|---|---|
Validation (422) |
{"detail": [{"type": ..., "loc": [...], "msg": ..., "input": ...}]} |
Chemin inconnu (404) ou méthode inconnue (405) |
{"detail": "Not Found"} |
Bogue dans le code (500) |
Internal Server Error (erreur interne du serveur) : texte brut, pas du JSON |
Le client doit traiter chaque structure séparément, et l’une d’elles n’est même pas du JSON. L’erreur de validation renvoie aussi la valeur input du client, qui peut être longue ou privée.
La solution est une seule structure d’erreur pour toutes les erreurs. Elle contient un code qu’un programme peut vérifier et un message qu’une personne peut lire :
{"error": {"code": "invalid_request", "message": "The request is not valid.", "fields": ["subject"]}}| Champ | Pour qui | Règle |
|---|---|---|
code |
Les programmes | Un mot fixe qui ne change jamais, comme invalid_request ou not_found. Les clients écrivent if code == "not_found". |
message |
Les personnes | Une courte phrase. Elle peut changer, donc les programmes ne doivent pas la comparer. |
fields |
Les deux | Les champs de la requête qui posent problème. Vide quand aucun champ n’est en cause. |
Gestionnaires d’exceptions
Section intitulée « Gestionnaires d’exceptions »Quand un problème survient, FastAPI lève une exception : RequestValidationError pour une entrée invalide, HTTPException pour 404 et 405. Un bogue peut lever toute autre exception. Un gestionnaire d’exceptions est une fonction qui transforme un type d’exception en réponse. Vous enregistrez un gestionnaire pour chaque type, et tous utilisent la même structure.
D’abord, définissez la structure avec des modèles Pydantic, à la fin de models.py. Ces modèles documentent aussi la structure, que vous utiliserez au module 4 :
class ErrorDetail(BaseModel): code: str message: str fields: list[str] = []
class ErrorResponse(BaseModel): error: ErrorDetailEnsuite, créez un module, errors.py, avec les gestionnaires :
import logging
from fastapi import FastAPI, Requestfrom fastapi.exceptions import RequestValidationErrorfrom fastapi.responses import JSONResponsefrom starlette.exceptions import HTTPException
from ticket_api.models import ErrorDetail, ErrorResponse
logger = logging.getLogger("ticket_api")
CODES = {404: "not_found", 405: "method_not_allowed"}
def field_name(error: dict) -> str: """Return the field that a validation error is about, such as "subject".""" if error["type"] == "json_invalid": return "body" return ".".join(str(part) for part in error["loc"][1:]) or "body"
def error_response(status: int, code: str, message: str, fields=()) -> JSONResponse: detail = ErrorDetail(code=code, message=message, fields=list(fields)) return JSONResponse( status_code=status, content=ErrorResponse(error=detail).model_dump() )
def add_error_handlers(app: FastAPI) -> None: @app.exception_handler(RequestValidationError) async def invalid_request(request: Request, exc: RequestValidationError): fields = [field_name(e) for e in exc.errors()] return error_response( 422, "invalid_request", "The request is not valid.", fields )
@app.exception_handler(HTTPException) async def http_error(request: Request, exc: HTTPException): code = CODES.get(exc.status_code, "http_error") return error_response(exc.status_code, code, str(exc.detail))
@app.exception_handler(Exception) async def unexpected(request: Request, exc: Exception): logger.exception("unexpected error") return error_response( 500, "internal_error", "Something went wrong on the server." )| Partie | Pourquoi |
|---|---|
field_name |
Le champ loc d’une erreur de validation contient un chemin comme ["body", "subject"]. Le client a besoin de subject. Pour du JSON illisible (json_invalid), le problème concerne tout le body. |
async def |
FastAPI exige que les gestionnaires soient des fonctions async. Vous n’avez pas besoin d’en savoir plus sur async ici. |
HTTPException de starlette |
FastAPI repose sur une bibliothèque appelée Starlette, qui lève les erreurs 404 et 405. Ce gestionnaire les intercepte aussi. |
Exception |
La dernière protection : toute erreur qu’aucun autre gestionnaire n’a interceptée, donc un bogue. Il consigne la trace complète pour vous et envoie un court message au client. |
Enregistrez les gestionnaires dans main.py, après la création de l’application :
from ticket_api.errors import add_error_handlers
app = FastAPI(title="Ticket Classifier API")app.state.classifier = KeywordClassifier()add_error_handlers(app)app.include_router(router)Rejeter les champs inconnus
Section intitulée « Rejeter les champs inconnus »Dans Modèles de requête et de réponse, l’API acceptait {"subject": "Refund", "colour": "red"} et ignorait colour. Cela cache les fautes de frappe. Un client qui envoie "subjct" reçoit une erreur déroutante sur l’absence de subject. Un client qui envoie "boddy" obtient un résultat basé uniquement sur le sujet, sans aucune erreur.
Une ligne dans TicketIn fait rejeter par Pydantic tous les champs que le modèle ne définit pas :
from pydantic import BaseModel, ConfigDict, Field
class TicketIn(BaseModel): model_config = ConfigDict(extra="forbid")
subject: str = Field(min_length=1, max_length=120) body: str = Field(default="", max_length=5000)Exemple détaillé : une structure pour toutes les erreurs
Section intitulée « Exemple détaillé : une structure pour toutes les erreurs »Amira envoie chaque requête correspondant à une ligne d’échec de son tableau de contrat :
| Requête | Statut | Corps |
|---|---|---|
{"subject": ""} |
422 |
{"error":{"code":"invalid_request","message":"The request is not valid.","fields":["subject"]}} |
{"subject": 42, "body": ["a"]} |
422 |
... "fields":["subject","body"] |
{"subject": "Hi", "colour": "red"} |
422 |
... "fields":["colour"] |
{"subject": "x" (sans accolade fermante) |
422 |
... "fields":["body"] |
GET /v1/classify |
405 |
{"error":{"code":"method_not_allowed","message":"Method Not Allowed","fields":[]}} |
GET /v1/nothing |
404 |
{"error":{"code":"not_found","message":"Not Found","fields":[]}} |
La valeur input du client ne figure plus dans la réponse. Le client sait déjà ce qu’il a envoyé.
Ne pas exposer les détails internes du serveur
Section intitulée « Ne pas exposer les détails internes du serveur »Une trace de pile, un chemin de fichier, une version de bibliothèque ou un message de base de données renseignent un attaquant. Ils lui montrent comment votre serveur fonctionne. Ils ne donnent aucune information utile à un utilisateur ordinaire. Le gestionnaire 500 envoie donc uniquement un message fixe. Pour le voir, Amira ajoute volontairement un bogue dans le classificateur. Dans predict, après lowered = text.lower(), elle ajoute lowered = lowered.casefolded() (la vraie méthode est casefold).
HTTP/1.1 500 Internal Server Error{"error":{"code":"internal_error","message":"Something went wrong on the server.","fields":[]}}unexpected errorTraceback (most recent call last): ... lowered = lowered.casefolded()AttributeError: 'str' object has no attribute 'casefolded'. Did you mean: 'casefold'?Le client apprend que le problème vient du serveur, et rien de plus. Vous obtenez la trace complète. (Uvicorn consigne l’erreur une deuxième fois, sous Exception in ASGI application (exception dans l’application ASGI). C’est normal.) Elle retire ensuite le bogue.
Exercice guidé : une seule structure
Section intitulée « Exercice guidé : une seule structure »- Ajoutez
ErrorDetailetErrorResponseàmodels.py, etextra="forbid"àTicketIn. - Créez
ticket_api/errors.py, puis enregistrez les gestionnaires dansmain.py. - Créez les fichiers du tableau :
requests/extra-field.jsonavec{"subject": "Hi", "colour": "red"}, etrequests/broken.jsonavec{"subject": "x"(sans accolade fermante). Vous avez les autres depuis le module 2. - Envoyez chaque requête du tableau. Vérifiez le code de statut et les
fieldsde chacune. - Ajoutez le bogue
casefoldedau classificateur, envoyezrequests/billing.json, puis comparez ce que voit le client et ce qu’affiche le terminal du serveur. Retirez ensuite le bogue.
À vous de jouer : une erreur levée par votre route
Section intitulée « À vous de jouer : une erreur levée par votre route »Vos routes peuvent elles-mêmes lever une erreur avec HTTPException. Ajoutez un point de terminaison GET /v1/categories/{name}, où {name} est un paramètre de chemin. FastAPI transmet cette partie du chemin à la fonction comme argument. Pour une catégorie connue, il renvoie {"name": ..., "keywords": [...]}, à partir de KEYWORDS dans le classificateur. Pour une catégorie inconnue, il lève HTTPException(status_code=404, detail="No such category.").
Testez /v1/categories/billing, /v1/categories/refund et /v1/categories/other. Prédisez d’abord chaque réponse. Quelle ligne devez-vous ajouter au tableau du contrat pour other?
Afficher une réponse
from fastapi import HTTPException
from ticket_api.classifier import KEYWORDS
@router.get("/categories/{name}")def get_category(name: str) -> dict: if name not in KEYWORDS: raise HTTPException(status_code=404, detail="No such category.") return {"name": name, "keywords": list(KEYWORDS[name])}billing donne 200 avec ses quatre mots-clés. refund donne 404, dans la structure standard, car votre gestionnaire transforme HTTPException en {"error":{"code":"not_found","message":"No such category.","fields":[]}}. other donne aussi 404, même si /v1/categories l’inclut : other n’a aucun mot-clé. C’est une question de contrat. Soit other renvoie 200 avec une liste vide, soit la liste l’exclut. Écrivez votre décision dans le tableau du contrat, puis adaptez le code. Retirez le point de terminaison avant la prochaine leçon. Le reste du cours ne l’utilise pas.
Erreur courante
Un succès qui cache un échec
Symptôme : un client signale des résultats incorrects, mais les journaux montrent uniquement des réponses 200. Dans le code, une route intercepte une erreur et renvoie quelque chose comme ceci :
try: prediction = classifier.predict(text)except Exception: return {"category": "other", "priority": 3, "confidence": 0.0, "model_version": "error"}Cause : la route a transformé un échec en « succès ». Le client reçoit 200 et une catégorie, sans pouvoir savoir que ce résultat n’est pas réel. Son programme classe le billet de soutien comme other, et personne ne l’examine. La surveillance qui compte les réponses 5xx ne voit rien.
Solution :
- Ne renvoyez jamais un code
2xxpour une opération qui a échoué. Laissez l’erreur atteindre un gestionnaire, ou levez uneHTTPExceptionavec le bon code. - Pour une partie temporairement indisponible, comme le classificateur, renvoyez
503avec son propre code. Vous le ferez au module 6. - Interceptez uniquement les exceptions attendues, comme dans le cours de Python, jamais toutes les
Exceptiondans une route.
Vérifiez votre compréhension
Le code d'un client vérifie si response['error']['message'] == 'The request is not valid.'. Quel est le problème, et que devrait-il vérifier?
Afficher la réponse
Le message s’adresse aux personnes et peut changer, par exemple après une correction orthographique ou une traduction. La vérification du client échoue alors sans signaler le problème. Il devrait vérifier le code de statut et error.code (invalid_request). Ils font partie du contrat et ne changent pas au sein d’une version.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
Pourquoi le gestionnaire 500 consigne-t-il l'exception, mais envoie-t-il uniquement un message fixe au client?
Afficher la réponse
La trace contient des chemins de fichiers, du code et des détails sur les bibliothèques qui peuvent aider un attaquant. Ils n’aident pas le client, qui ne peut pas corriger un bogue sur le serveur. Le journal conserve tous les détails pour les personnes capables de le corriger. Le client doit seulement savoir qu’il s’agit d’une erreur du serveur (500, internal_error).
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Récapitulatif
- Utilisez une seule structure pour toutes les erreurs : un
codestable pour les programmes, unmessagepour les personnes et lesfieldsqui posent problème. - Les gestionnaires d’exceptions transforment chaque type d’exception en cette structure. Le gestionnaire de
Exceptionintercepte les bogues. extra="forbid"rejette les champs inconnus, pour ne pas ignorer les fautes de frappe dans leurs noms.- N’envoyez jamais de trace de pile ni de détails internes au client. Consignez-les dans les journaux.
- Ne renvoyez jamais un succès pour une opération qui a échoué.
Ensuite, vous permettrez à une page Web dans un navigateur d’appeler votre API, mais uniquement aux pages que vous choisissez.
Leçon suivante : Accès depuis le navigateur et CORS