Aller au contenu
English

Validation et réponses d'erreur

  • Leçon 8
  • 45 min
  • Module 3 : Concevoir un contrat utilisable

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 mainEnviron 45 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

Testé avec Python 3.14.6 et FastAPI 0.142.2 sur macOS.

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 :

The error shape
{"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.

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 :

ticket_api/models.py (add at the end)
class ErrorDetail(BaseModel):
code: str
message: str
fields: list[str] = []
class ErrorResponse(BaseModel):
error: ErrorDetail

Ensuite, créez un module, errors.py, avec les gestionnaires :

ticket_api/errors.py
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from 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 :

ticket_api/main.py (changes)
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)

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 :

ticket_api/models.py (changes)
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é.

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

The client sees
HTTP/1.1 500 Internal Server Error
{"error":{"code":"internal_error","message":"Something went wrong on the server.","fields":[]}}
The server's terminal shows (end)
unexpected error
Traceback (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.

  1. Ajoutez ErrorDetail et ErrorResponse à models.py, et extra="forbid" à TicketIn.
  2. Créez ticket_api/errors.py, puis enregistrez les gestionnaires dans main.py.
  3. Créez les fichiers du tableau : requests/extra-field.json avec {"subject": "Hi", "colour": "red"}, et requests/broken.json avec {"subject": "x" (sans accolade fermante). Vous avez les autres depuis le module 2.
  4. Envoyez chaque requête du tableau. Vérifiez le code de statut et les fields de chacune.
  5. Ajoutez le bogue casefolded au classificateur, envoyez requests/billing.json, puis comparez ce que voit le client et ce qu’affiche le terminal du serveur. Retirez ensuite le bogue.

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
ticket_api/routes.py (add)
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 :

  1. Ne renvoyez jamais un code 2xx pour une opération qui a échoué. Laissez l’erreur atteindre un gestionnaire, ou levez une HTTPException avec le bon code.
  2. Pour une partie temporairement indisponible, comme le classificateur, renvoyez 503 avec son propre code. Vous le ferez au module 6.
  3. Interceptez uniquement les exceptions attendues, comme dans le cours de Python, jamais toutes les Exception dans 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.

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

Récapitulatif

  • Utilisez une seule structure pour toutes les erreurs : un code stable pour les programmes, un message pour les personnes et les fields qui posent problème.
  • Les gestionnaires d’exceptions transforment chaque type d’exception en cette structure. Le gestionnaire de Exception intercepte 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