Configuration et journalisation
- Leçon 17
- 45 min
- Module 6 : Tester et renforcer le service
Leçon 17 sur 21 · Créer et déployer votre première APIConfiguration et journalisation
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
Quand un utilisateur signale « J’ai reçu une erreur », trouvez cette requête précise dans vos journaux en quelques secondes. Exécutez aussi le même code avec différents paramètres : sur votre ordinateur, dans les tests et sur le serveur, sans modifier le code.
Avant de commencerCe qu’il faut savoir et avoir sous la main
Ce que vous devez savoir
- Les variables d’environnement et un module de paramètres, dans Configuration et secrets : exemple détaillé : un module de paramètres.
- La journalisation, dans Exceptions et journalisation : la journalisation plutôt que print.
Ce qu’il vous faut sur votre ordinateur
tests/test_classify.py,ClassifierUnavailableet le gestionnaire503, dans Tests automatisés des points de terminaison : exercice guidé : tester tous les chemins.python -m pytestréussit.main.pylitALLOWED_ORIGINS, dans Accès depuis le navigateur et CORS : exemple détaillé : autoriser une origine, etSHOW_DOCS, dans Garder la documentation exacte : décider quoi exposer en production. Cette leçon déplace ces deux paramètres dansconfig.py.ticket_api/export_contract.py, dans Garder la documentation exacte : exemple détaillé : le contrat révisé. Vous le modifiez dans cette leçon.- Si votre projet manque ou ne fonctionne plus, copiez l’instantané
end-of-m05, le projet à la fin du module 5, comme dans Utiliser un instantané. Puis refaites Tests automatisés des points de terminaison : exercice guidé : tester tous les chemins.
Testé avec Python 3.14.6 et FastAPI 0.142.2 sur macOS.
Un seul endroit pour tous les paramètres
Section intitulée « Un seul endroit pour tous les paramètres »L’API lit maintenant trois variables d’environnement dans main.py (ALLOWED_ORIGINS, SHOW_DOCS), à deux endroits différents. Le classificateur est fixé dans le code. Comme dans le cours de Python, les paramètres appartiennent à un seul module, avec des valeurs par défaut sûres :
import osfrom dataclasses import dataclass
@dataclass(frozen=True)class Settings: allowed_origins: list[str] classifier_mode: str log_level: str show_docs: bool
def load_settings() -> Settings: """Read the settings from environment variables, with safe defaults.""" origins = os.environ.get("ALLOWED_ORIGINS", "") return Settings( allowed_origins=[o.strip() for o in origins.split(",") if o.strip()], classifier_mode=os.environ.get("CLASSIFIER_MODE", "keywords"), log_level=os.environ.get("LOG_LEVEL", "INFO").upper(), show_docs=os.environ.get("SHOW_DOCS", "true").lower() == "true", )| Variable | Valeur par défaut | Signification |
|---|---|---|
ALLOWED_ORIGINS |
aucune | Pages Web qui peuvent appeler l’API depuis un navigateur (module 3). |
CLASSIFIER_MODE |
keywords |
keywords (mots-clés) est le vrai classificateur. unavailable (indisponible) simule un classificateur en panne. |
LOG_LEVEL |
INFO |
DEBUG (débogage), INFO (information), WARNING (avertissement) ou ERROR (erreur). |
SHOW_DOCS |
true |
false (faux) désactive /docs, /redoc et /openapi.json (module 4). |
CLASSIFIER_MODE=unavailable vous permet d’observer le comportement de l’API quand sa dépendance est en panne. Vous le faites sur votre ordinateur et, au module 7, sur le serveur. Ce mode utilise un petit classificateur qui échoue toujours, à la fin de classifier.py :
class UnavailableClassifier: """Simulates a classifier that is down."""
version = "unavailable"
def predict(self, text: str) -> Prediction: raise ClassifierUnavailable("the classifier is not available")
def make_classifier(mode: str) -> KeywordClassifier | UnavailableClassifier: classifiers = {"keywords": KeywordClassifier, "unavailable": UnavailableClassifier} if mode not in classifiers: raise ValueError(f"unknown CLASSIFIER_MODE {mode!r}") return classifiers[mode]()Un mode inconnu arrête l’application au démarrage, avec un message clair. C’est mieux que de démarrer avec le mauvais classificateur.
Une fabrique d’application
Section intitulée « Une fabrique d’application »Jusqu’ici, main.py créait l’application quand Python l’importait, avec les paramètres du moment. Un test qui veut d’autres paramètres, comme classifier_mode="unavailable", ne peut pas les modifier. La solution est une fabrique : une fonction qui crée une nouvelle application à partir des paramètres que vous lui passez.
def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or load_settings() # ... make the app with these settings, add middleware, handlers and routes return app
app = create_app()fastapi dev ticket_api/main.py trouve toujours app, créée à partir des variables d’environnement. Les tests appellent create_app(...) avec leurs propres paramètres et obtiennent une nouvelle application chaque fois.
Un identifiant pour chaque requête
Section intitulée « Un identifiant pour chaque requête »Quand un utilisateur signale une erreur, les journaux doivent montrer cette requête précise. L’API donne à chaque requête un court identifiant aléatoire. Elle le place dans l’en-tête de réponse X-Request-ID et dans chaque corps d’erreur, puis l’écrit dans sa ligne de journal. L’utilisateur copie l’identifiant de l’erreur, et vous le cherchez dans les journaux.
Un intergiciel s’exécute avant et après chaque requête, comme l’intergiciel CORS. Voici le nouveau main.py au complet :
import loggingimport timeimport uuid
from fastapi import FastAPI, Requestfrom fastapi.middleware.cors import CORSMiddleware
from ticket_api.classifier import make_classifierfrom ticket_api.config import Settings, load_settingsfrom ticket_api.errors import add_error_handlersfrom ticket_api.models import Healthfrom ticket_api.routes import router
logger = logging.getLogger("ticket_api")
DESCRIPTION = """Classifies support tickets into a category and a priority.
The classifier is a deterministic mock: it uses keywords, so the same textalways gives the same result. Every error has the same shape:`{"error": {"code": ..., "message": ..., "request_id": ..., "fields": [...]}}`."""
def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or load_settings() logging.basicConfig( level=settings.log_level, format="%(levelname)s %(name)s: %(message)s" )
app = FastAPI( title="Ticket Classifier API", version="1.0.0", description=DESCRIPTION, openapi_tags=[ {"name": "tickets", "description": "Classify support tickets."}, {"name": "health", "description": "Check that the service runs."}, ], docs_url="/docs" if settings.show_docs else None, redoc_url="/redoc" if settings.show_docs else None, openapi_url="/openapi.json" if settings.show_docs else None, ) app.state.settings = settings app.state.classifier = make_classifier(settings.classifier_mode)
@app.middleware("http") async def request_id_and_log(request: Request, call_next): request.state.request_id = uuid.uuid4().hex[:12] started = time.perf_counter() response = await call_next(request) response.headers["X-Request-ID"] = request.state.request_id logger.info( "%s %s %d %.0fms id=%s", request.method, request.url.path, response.status_code, (time.perf_counter() - started) * 1000, request.state.request_id, ) return response
if settings.allowed_origins: app.add_middleware( CORSMiddleware, allow_origins=settings.allowed_origins, allow_methods=["GET", "POST"], allow_headers=["Content-Type"], )
add_error_handlers(app) app.include_router(router)
@app.get("/health", tags=["health"], summary="Check that the service runs") def health() -> Health: return Health(status="ok")
return app
app = create_app()| Partie | Raison |
|---|---|
uuid.uuid4().hex[:12] |
Un identifiant aléatoire, comme 5f5cb5124f1a. 12 caractères suffisent pour qu’il soit unique dans vos journaux. |
request.state.request_id |
Conserve l’identifiant avec cette requête pour que les gestionnaires d’erreurs puissent l’utiliser. |
await call_next(request) |
Exécute le reste de l’application : la route et les gestionnaires. L’intergiciel reçoit la réponse. |
| La ligne de journal | Méthode, chemin, statut, durée et identifiant. Pas le corps, ni les en-têtes. |
L’intergiciel journalise seulement des faits sur la requête, jamais son contenu. Le corps d’un billet de soutien peut contenir des données personnelles d’un client, et un en-tête peut contenir une clé d’API. Les journaux sont conservés longtemps et lus par beaucoup de gens. Ils ne doivent donc contenir ni l’un ni l’autre.
L’identifiant dans le corps d’erreur
Section intitulée « L’identifiant dans le corps d’erreur »La structure d’erreur reçoit un champ de plus, dans models.py :
request_id: str | None = Field( default=None, description="Give this ID when you report a problem." )Dans errors.py, error_response reçoit la requête comme premier paramètre et y lit l’identifiant. Chaque gestionnaire passe request :
def error_response( request: Request, status: int, code: str, message: str, fields=()) -> JSONResponse: detail = ErrorDetail( code=code, message=message, request_id=getattr(request.state, "request_id", None), fields=list(fields), ) return JSONResponse( status_code=status, content=ErrorResponse(error=detail).model_dump() )Chaque appel passe de error_response(422, ...) à error_response(request, 422, ...). Les deux lignes de journal des gestionnaires reçoivent aussi l’identifiant : logger.warning("classifier unavailable: %s id=%s", exc, request.state.request_id) et logger.exception("unexpected error id=%s", request.state.request_id).
Des tests avec leurs propres paramètres
Section intitulée « Des tests avec leurs propres paramètres »Un fichier nommé conftest.py dans tests contient des fixtures (fonctions de préparation) utilisables par chaque fichier de tests. Il donne à chaque test une nouvelle application avec des paramètres de test, et un moyen de modifier un paramètre :
from dataclasses import replace
import pytestfrom fastapi.testclient import TestClient
from ticket_api.config import Settingsfrom ticket_api.main import create_app
TEST_SETTINGS = Settings( allowed_origins=[], classifier_mode="keywords", log_level="WARNING", show_docs=True,)
@pytest.fixturedef make_client(): """Return a function that makes a test client with changed settings."""
def make(**changes) -> TestClient: app = create_app(replace(TEST_SETTINGS, **changes)) return TestClient(app, raise_server_exceptions=False)
return make
@pytest.fixturedef client(make_client) -> TestClient: return make_client()replace (de dataclasses) copie les paramètres figés en modifiant certains champs. make_client renvoie une fonction, ce qui permet à un test de demander les paramètres dont il a besoin. La plupart des tests demandent seulement client.
Les tests changent de trois petites façons :
-
Retirez
client = TestClient(app)et ses importations des deux fichiers de tests. Donnez plutôt un paramètreclientà chaque fonction de test :def test_health(client):. -
Remplacez la fixture du classificateur en panne par le paramètre, ce qui est plus simple :
tests/test_classify.py def test_unavailable_classifier_gives_503(make_client):client = make_client(classifier_mode="unavailable")response = client.post("/v1/classify", json={"subject": "Refund"})assert response.status_code == 503assert response.json()["error"]["code"] == "classifier_unavailable"assert "Traceback" not in response.textdef test_every_response_has_a_request_id(client):ok = client.get("/health")error = client.post("/v1/classify", json={"subject": ""})assert len(ok.headers["X-Request-ID"]) == 12assert error.json()["error"]["request_id"] == error.headers["X-Request-ID"] -
Dans
export_contract.py, importezcreate_appau lieu deapp, et utilisezcreate_app().openapi().
Exemple détaillé : suivre une requête
Section intitulée « Exemple détaillé : suivre une requête »Amira exécute les tests. Le test du contrat échoue. La différence montre le nouveau champ : "request_id": ... dans la description et dans le schéma ErrorDetail. Un nouveau champ facultatif dans une erreur ne rompt pas la compatibilité. Elle exécute donc python -m ticket_api.export_contract, et les 13 tests réussissent.
Elle envoie ensuite un billet invalide à l’API en cours d’exécution :
{"error":{"code":"invalid_request","message":"The request is not valid.","request_id":"5f5cb5124f1a","fields":["subject"]}}INFO ticket_api: POST /v1/classify 422 1ms id=5f5cb5124f1aL’identifiant dans la réponse et celui dans le journal sont identiques. Avec CLASSIFIER_MODE=unavailable, le journal indique aussi pourquoi :
WARNING ticket_api: classifier unavailable: the classifier is not available id=073a3ced6d65INFO ticket_api: POST /v1/classify 503 2ms id=073a3ced6d65(Uvicorn écrit aussi sa propre ligne pour chaque requête, qui commence par INFO: 127.0.0.1:.... Vos lignes commencent par INFO ticket_api:.)
Exercice guidé : paramètres et identifiants de requête
Section intitulée « Exercice guidé : paramètres et identifiants de requête »- Créez
config.py, ajoutezUnavailableClassifieretmake_classifieràclassifier.py, puis remplacezmain.py. - Ajoutez
request_idàErrorDetailet modifiezerrors.py. - Créez
tests/conftest.py, puis modifiez les tests etexport_contract.py. - Exécutez les tests. Lisez la différence du contrat, exportez le contrat, puis exécutez les tests de nouveau. Tous réussissent.
- Envoyez une requête valide et une requête invalide à l’API en cours d’exécution. Trouvez l’identifiant de chacune dans la réponse (en-tête ou corps) et dans le terminal de l’API.
- Arrêtez l’API, puis démarrez-la avec
CLASSIFIER_MODE=unavailable($env:CLASSIFIER_MODE = "unavailable"dans PowerShell). Envoyez un billet. Vous obtenez503, et le journal contient unWARNINGavec le même identifiant. Redémarrez-la sans la variable. - Démarrez-la avec
CLASSIFIER_MODE=magic. Elle s’arrête immédiatement avecValueError: unknown CLASSIFIER_MODE 'magic'(valeur de CLASSIFIER_MODE inconnue : « magic »).
À vous de jouer : une demande de soutien
Section intitulée « À vous de jouer : une demande de soutien »Un utilisateur écrit : « La classification a échoué à 14 h 02, l’erreur indiquait request_id 9b1c0e7a44d2 ». Notez les étapes exactes que vous suivriez pour comprendre ce qui s’est passé, avec vos journaux actuels. Nommez ensuite une information que les journaux ne donnent pas, et dites si son ajout serait sûr.
Afficher une réponse
Cherchez 9b1c0e7a44d2 dans les journaux de l’API. La ligne INFO donne la méthode, le chemin, le code de statut et la durée. Pour un 503, une ligne WARNING avec le même identifiant indique que le classificateur était indisponible. Pour un 500, une ligne ERROR contient la trace complète de l’erreur.
Les journaux ne contiennent pas le texte du billet. Vous ne pouvez donc pas reproduire la requête exacte. Le journaliser n’est pas sûr par défaut : les billets peuvent contenir des données personnelles. Au besoin, demandez à l’utilisateur d’envoyer le texte. Sinon, journalisez seulement sa longueur ou une catégorie, jamais le contenu.
Erreur courante
Un secret dans les journaux
Symptôme : un collègue lit les journaux pour trouver un bogue et y voit des clés d’API ou des messages de clients.
Cause : quelqu’un a ajouté une ligne de journal contenant toute la requête pour déboguer un problème, puis l’a laissée. Par exemple : logger.info("request %s %s", request.headers, body). Les journaux sont copiés vers des services de journalisation, conservés pendant des mois et lus par beaucoup de gens. Chaque secret qu’ils contiennent a été divulgué.
Solution :
- Retirez la ligne de journal. Journalisez seulement des faits : méthode, chemin, statut, durée, identifiant.
- Traitez chaque clé apparue dans les journaux comme divulguée : révoquez-la et créez-en une nouvelle.
- Si vous avez besoin de plus de détails pour déboguer, utilisez une ligne
DEBUGavec seulement des champs sûrs. Retirez-la après la correction.
Vérifiez votre compréhension
Pourquoi create_app() reçoit-elle les paramètres en argument, au lieu de toujours appeler load_settings()?
Afficher la réponse
Pour que chaque appelant puisse décider d’où viennent les paramètres. Le serveur utilise les variables d’environnement (create_app() sans argument). Un test passe ses propres paramètres, par exemple classifier_mode="unavailable", sans définir de variables d’environnement qui affecteraient d’autres tests. Chaque test reçoit une nouvelle application. Aucun état ne passe donc d’un test au suivant.
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
- Lisez tous les paramètres dans
config.py, avec des valeurs par défaut sûres. Une valeur inconnue arrête l’application au démarrage. create_app(settings)crée une nouvelle application. Le serveur et les tests passent chacun leurs propres paramètres.- Un intergiciel donne un identifiant à chaque requête. Il le place dans l’en-tête
X-Request-IDet le corps d’erreur, puis le journalise. - Journalisez des faits sur les requêtes, jamais leur contenu, leurs en-têtes ou leurs secrets.
conftest.pycontient des fixtures partagées, comme un client avec des paramètres modifiés.
Ensuite, vous protégez l’API : une clé d’API, une limite de taille du corps et une limite de durée pour le classificateur.
Leçon suivante : Protection de base