Le middleware : du code qui s'exécute à chaque requête
- Expliquer ce qu’est un middleware et quel problème il résout
- Écrire un middleware avec @app.middleware(“http”) et call_next
- Comprendre ce que font async et await dans un middleware
- Ajouter à l’API des étudiants un journal et une mesure du temps pour chaque requête, sans modifier aucune route
Qu’allons-nous apprendre ?
- Quel problème se pose quand on veut faire quelque chose à chaque requête.
- Ce qu’est un middleware, et quel est son lien avec le décorateur que nous connaissons déjà.
@app.middleware("http"),call_next,asyncetawait.- Un journal et une mesure du temps pour chaque requête de l’API des étudiants, et un en-tête (Header) ajouté à chaque réponse.
Pourquoi en avons-nous besoin ?
L’API des étudiants de la leçon précédente fonctionne. Maintenant, nous voulons savoir ce qui s’y passe : quelle requête est arrivée, quel code est revenu, et combien de temps cela a pris. La première idée qui vient à l’esprit — ajouter une ligne de journal dans chaque route :
@app.get("/students")
def list_students():
logging.info("GET /students")
return students
@app.get("/students/{student_id}")
def get_student(student_id: int):
logging.info("GET /students/" + str(student_id))
...Trois problèmes :
- La même ligne est copiée dans chaque route — cinq, dix, cinquante fois.
- Une nouvelle route où l’on a oublié la ligne — n’est tout simplement pas journalisée.
- On ne peut pas connaître ainsi le code de réponse — les 404 et 400 sont créés dans
raise HTTPException, après la ligne de journal, et le 422 est créé avant même que la route ne s’exécute. Ni combien de temps a pris la requête.
Explication à partir des bases
L’idée — le middleware
Middleware = du code qui enveloppe chaque requête arrivant au serveur : il s’exécute avant la route, transmet la requête, et s’exécute de nouveau après — quand la réponse est déjà là.
Client --request--> Middleware --------> Route (your function)
before: start the timer
Client <-response-- Middleware <-------- Route
after: status code, duration, logComme un décorateur — pour tout le serveur
Dans la leçon sur les décorateurs, nous avons écrit un décorateur qui mesure le temps : il enveloppe une seule fonction, s’exécute avant et après elle, et l’appelle au milieu. Le middleware est exactement la même idée — sauf qu’il enveloppe toutes les requêtes vers le serveur, sans toucher à aucune route.
| Décorateur | Middleware | |
|---|---|---|
| Enveloppe | Une seule fonction | Chaque requête vers le serveur |
| L’appel au milieu | func(*args, **kwargs) | await call_next(request) |
| Où on l’ajoute | @timed au-dessus de chaque fonction | Une seule fois, dans main.py |
Tous ceux qui entrent dans l’immeuble passent devant le gardien — quel que soit l’étage où ils vont. Le gardien note qui est entré et quand, et qui est sorti et quand. Il n’a pas besoin de se tenir à côté de chaque bureau. Middleware = le gardien à l’entrée, et les routes = les bureaux.
Pourquoi faut-il bien attendre ?
Le middleware transmet la requête à la route, puis attend la réponse. Mais un serveur reçoit de nombreuses requêtes en même temps. S’il « se bloque » et attend chaque requête jusqu’à la fin, toutes les autres font la queue.
Un serveur transmet une commande à la cuisine. S’il reste planté à côté de la cuisine jusqu’à ce que le plat soit prêt — toutes les autres tables l’attendent. Un bon serveur dit « prévenez-moi quand le plat est prêt », et continue de prendre les commandes des autres tables. Quand le plat est prêt — il revient, le prend et le sert.
async et await
C’est exactement à cela que sert await. Dans le middleware, il y a la ligne :
response = await call_next(request)call_next(request)— transmet la requête : à la route correspondante (ou à un 404 s’il n’y en a pas), et renvoie la réponse. C’est la commande à la cuisine.await— « prévenez-moi quand le plat est prêt » : attendre la réponse de la route, et pendant ce temps le serveur est libre de traiter d’autres requêtes. Quand la réponse est prête, le code continue à la ligne suivante.async def—awaitne peut s’écrire **qu’**à l’intérieur d’une fonction définie avecasync def. C’est pourquoi le middleware est défini ainsi.
Seulement ce modèle : async def en haut, et await avant call_next. Nos routes restent un def ordinaire — FastAPI s’en occupe tout seul. Comment le serveur gère l’attente en coulisses (la boucle d’événements, asyncio) — nous l’apprendrons plus tard dans le parcours.
request et response
| Objet | Ce qu’il contient (ce que nous utiliserons) |
|---|---|
request.method | "GET", "POST"… |
request.url.path | Le chemin, par exemple "/students/2" |
response.status_code | Le code de réponse : 200, 404… |
response.headers | Les en-têtes de la réponse — fonctionne comme un dictionnaire |
Syntaxe
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def my_middleware(request: Request, call_next):
# ... before the route
response = await call_next(request) # wait for the route's response
# ... after the route
return response # always pass the response on@app.middleware("http")— enregistre la fonction comme middleware pour chaque requête HTTP.request: Request—Requests’importe depuisfastapi.return response— obligatoire. Sans lui, le client ne reçoit pas de réponse.
Premier exemple de code
Un journal et une mesure du temps pour chaque requête de l’API des étudiants. En haut de students_memory.py (de la leçon précédente) — nous ne faisons qu’ajouter, aucune route ne change :
import logging
import time
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
app = FastAPI(title="Students API")
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
response = await call_next(request)
ms = round((time.time() - start) * 1000, 1)
logging.info(request.method + " " + request.url.path + " -> "
+ str(response.status_code) + " (" + str(ms) + " ms)")
return response
# ... students, StudentIn and all the routes stay exactly the sameLancez-le (uvicorn students_memory:app --reload), et envoyez des requêtes depuis Swagger (/docs). Le journal dans le terminal :
INFO GET /students -> 200 (0.7 ms)
INFO GET /students/99 -> 404 (0.5 ms)
INFO POST /students -> 201 (1.3 ms)
INFO POST /students -> 400 (0.6 ms)
INFO GET /students/abc -> 422 (0.5 ms)Analyse du code ligne par ligne
| Code | Explication |
|---|---|
logging.basicConfig(...) | La configuration du journal, une seule fois (les leçons sur la journalisation). |
@app.middleware("http") | La fonction suivante s’exécutera à chaque requête. |
async def log_requests(request: Request, call_next): | La requête, et call_next — le moyen de continuer vers la route. |
start = time.time() | Avant la route : démarrer la mesure. |
response = await call_next(request) | Exécuter la route et attendre la réponse. |
ms = round((time.time() - start) * 1000, 1) | Après : combien de temps s’est écoulé, en millisecondes. |
logging.info(...) | Une ligne pour chaque requête : la méthode, le chemin, le code et le temps. Aussi les 404, 400 et 422 — car le middleware voit la réponse finale, même quand la route ne s’est pas exécutée du tout. |
return response | Transmettre la réponse au client. |
Remarquez : les temps sont très courts — les données sont dans une liste en mémoire. Quand nous connecterons le serveur à une base de données, nous verrons les temps augmenter — et une mesure comme celle-ci est exactement le moyen de découvrir où le serveur « perd » du temps.
Un autre exemple
Un en-tête pour chaque réponse
Un middleware peut aussi modifier la réponse. Ici, nous ajoutons à chaque réponse un en-tête nommé X-Process-Time, avec le temps qu’a pris son traitement :
@app.middleware("http")
async def add_process_time(request: Request, call_next):
start = time.time()
response = await call_next(request)
ms = round((time.time() - start) * 1000, 1)
response.headers["X-Process-Time"] = str(ms)
return responseDans Swagger, sous Response headers, ceci apparaît maintenant :
x-process-time: 0.7response.headers[...] = ...— comme ajouter une clé à un dictionnaire.X-au début du nom — une convention pour un en-tête à nous, qui ne fait pas partie de la norme HTTP.- On peut avoir plusieurs middlewares dans le même serveur — chaque requête passe par tous.
Quand ne pas utiliser de middleware ?
Le middleware convient aux choses dont chaque requête a besoin — un journal, une mesure du temps, des en-têtes. Une règle propre à une seule route — par exemple « âge minimum 16 » dans POST /students — appartient à la route elle-même, et non à du code qui s’exécute sur tout le serveur.
Erreur courante
Erreur 1 : oublier return response.
@app.middleware("http")
async def log_requests(request: Request, call_next):
response = await call_next(request)
logging.info(request.url.path)Le middleware renvoie None, et le client reçoit un 500 — à chaque requête, car le middleware s’exécute sur toutes.
Erreur 2 : oublier await.
response = call_next(request)Sans await, on n’attend pas la route — et response n’est pas une réponse. Le serveur échoue à chaque requête. await avant call_next, toujours.
Erreur 3 : def au lieu de async def. Sans async, on ne peut pas utiliser await dans la fonction : SyntaxError: 'await' outside async function.
Erreur 4 : de la logique de route dans un middleware. if request.url.path == "/students" and ... — un middleware qui vérifie des chemins précis est généralement le signe que le code appartient à une route.
Exercices
Les exercices n’utilisent que ce qui a été vu dans cette leçon et les précédentes. Essayez d’abord seul, et n’ouvrez la solution qu’ensuite.
Exercice 1: Qu’est-ce qui s’exécute en premier ?
@app.middleware("http")
async def trace(request: Request, call_next):
print("before", request.method, request.url.path)
response = await call_next(request)
print("after", response.status_code)
return response
@app.get("/hello")
def hello():
print("inside the route")
return {"message": "hi"}Vous envoyez GET /hello. Qu’est-ce qui sera affiché, et dans quel ordre ? Et qu’est-ce qui sera affiché pour GET /nothing, un chemin qui n’existe pas ?
Afficher la solution
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
app = FastAPI()
@app.middleware("http")
async def trace(request: Request, call_next):
print("before", request.method, request.url.path)
response = await call_next(request)
print("after", response.status_code)
return response
@app.get("/hello")
def hello():
print("inside the route")
return {"message": "hi"}
client = TestClient(app)
client.get("/hello")
client.get("/nothing")before GET /hello
inside the route
after 200
before GET /nothing
after 404- Le middleware s’exécute avant la route,
await call_next(request)exécute la route, puis on continue après — avec la réponse en main. - Une requête vers un chemin qui n’existe pas passe aussi par le middleware : il n’y a pas de route à exécuter, et FastAPI renvoie 404 — et le middleware le voit aussi.
Exercice 2: Un journal pour chaque requête
Écrivez un middleware nommé log_requests qui écrit dans le journal, au niveau INFO, une ligne pour chaque requête : la méthode, le chemin et le code de réponse — au format du résultat attendu.
Utilisez cette petite application : une liste avec un étudiant, GET /students et GET /students/{student_id} (qui renvoie 404 pour un étudiant qui n’existe pas). Configurez le journal pour que les lignes s’affichent à l’écran :
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s", stream=sys.stdout)
logging.getLogger("httpx").setLevel(logging.WARNING) # hide the test client's own log lines
students = [{"id": 1, "name": "Dana"}]Pour tester sans lancer de serveur, envoyez les trois requêtes avec le TestClient de FastAPI — un outil qui envoie des requêtes directement à l’application depuis du code Python :
from fastapi.testclient import TestClient
client = TestClient(app)
client.get("/students")
client.get("/students/1")
client.get("/students/99")Résultat attendu :
INFO GET /students -> 200
INFO GET /students/1 -> 200
INFO GET /students/99 -> 404Afficher la solution
import logging
import sys
from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s", stream=sys.stdout)
logging.getLogger("httpx").setLevel(logging.WARNING) # hide the test client's own log lines
app = FastAPI()
students = [{"id": 1, "name": "Dana"}]
@app.middleware("http")
async def log_requests(request: Request, call_next):
response = await call_next(request)
logging.info(request.method + " " + request.url.path + " -> " + str(response.status_code))
return response
@app.get("/students")
def list_students():
return students
@app.get("/students/{student_id}")
def get_student(student_id: int):
for student in students:
if student["id"] == student_id:
return student
raise HTTPException(status_code=404, detail="student not found")
client = TestClient(app)
client.get("/students")
client.get("/students/1")
client.get("/students/99")INFO GET /students -> 200
INFO GET /students/1 -> 200
INFO GET /students/99 -> 404(stream=sys.stdout et la ligne httpx ne sont nécessaires que pour tester avec TestClient. Sur un vrai serveur, basicConfig avec level et format suffit.)
Une seule ligne de journal, à un seul endroit — et aucune route n’a changé. Une route ajoutée demain entrera aussi automatiquement dans le journal.
Exercice 3: Un en-tête avec le temps de traitement
Écrivez un middleware qui mesure combien de temps a pris le traitement de la requête, et ajoute à la réponse un en-tête nommé X-Process-Time avec le temps en millisecondes. L’application a une seule route, GET / qui renvoie {"message": "hello"}.
Vérifiez que l’en-tête arrive — envoyez une requête avec TestClient (un outil qui envoie des requêtes directement à l’application depuis du code Python) et vérifiez les en-têtes de la réponse :
from fastapi.testclient import TestClient
client = TestClient(app)
response = client.get("/")
print(response.status_code)
print("Has X-Process-Time:", "x-process-time" in response.headers)Résultat attendu :
200
Has X-Process-Time: TrueAfficher la solution
import time
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
app = FastAPI()
@app.middleware("http")
async def add_process_time(request: Request, call_next):
start = time.time()
response = await call_next(request)
ms = round((time.time() - start) * 1000, 1)
response.headers["X-Process-Time"] = str(ms)
return response
@app.get("/")
def home():
return {"message": "hello"}
client = TestClient(app)
response = client.get("/")
print(response.status_code)
print("Has X-Process-Time:", "x-process-time" in response.headers)200
Has X-Process-Time: Truetime.time()avant et après — exactement comme dans le décorateur qui mesure le temps.response.headersfonctionne comme un dictionnaire. Les en-têtes ne dépendent pas des majuscules et minuscules, doncx-process-timetrouveX-Process-Time.
Exercice 4: async et await
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
response = await call_next(request)
ms = round((time.time() - start) * 1000, 1)
logging.info(request.url.path + " (" + str(ms) + " ms)")
return response- Que fait la ligne
response = await call_next(request)? Expliquez avec l’analogie du serveur de restaurant et de la cuisine. - Que fait le serveur pendant que
awaitattend ? - Pourquoi la fonction est-elle définie avec
async defet non avecdef? - Nos routes (
list_students,get_student) doivent-elles aussi êtreasync def?
Afficher la solution
call_next(request)transmet la requête à la route — c’est la commande à la cuisine.await, c’est « prévenez-moi quand le plat est prêt » : attendre la réponse de la route, et quand elle est prête, le code continue à la ligne suivante — avec la réponse dansresponse.- Il traite d’autres requêtes. Comme un serveur qui ne reste pas planté à côté de la cuisine mais continue de prendre les commandes des autres tables — le serveur ne « se bloque » pas sur une seule requête.
- Parce que
await**ne peut s’écrire qu’**à l’intérieur d’une fonction définie avecasync def. Avec undefordinaire, Python n’acceptera même pas la ligne :SyntaxError: 'await' outside async function. - Non. Les routes restent un
defordinaire — FastAPI s’en occupe tout seul. Dans ce cours,asyncetawaitn’apparaissent que dans le middleware, selon ce modèle.
Exercice 5: Qu’est-ce qui ne va pas ?
@app.middleware("http")
def log_requests(request: Request, call_next):
response = call_next(request)
logging.info(request.url.path)Trouvez trois problèmes. Et quand n’est-il pas judicieux d’utiliser un middleware ?
Afficher la solution
defau lieu deasync def— un middleware dans FastAPI doit êtreasync def.- Il manque
awaitavantcall_next(request)— sans lui, on n’attend pas la route, etresponsen’est pas la réponse. - Il manque
return response— le middleware doit renvoyer la réponse plus loin. Sans cela, le client ne reçoit rien, et le serveur échoue.
Quand ne pas l’utiliser : le middleware convient aux choses dont chaque requête a besoin — un journal, une mesure du temps, des en-têtes. Une règle métier d’une seule route — par exemple « âge minimum 16 » dans POST /students — appartient à la route elle-même (ou à une fonction de validation), pas à un middleware qui s’exécute sur tout le serveur.
Résumé
- Middleware = du code qui enveloppe chaque requête : avant la route, et après. Comme un décorateur — pour tout le serveur.
@app.middleware("http")+async def+await call_next(request)+return response.- Avant
call_next— ce qu’il faut avant la requête (par exemple, démarrer la mesure). Après — on a la réponse en main : le code, les en-têtes, le temps. - Usages : un journal pour chaque requête, la mesure du temps, des en-têtes pour chaque réponse.
- Les règles d’une seule route — dans la route, pas dans le middleware.