backend / fastapi
Middlewares custom
Explication
Ce que vous allez apprendre
- Écrire un middleware avec
@app.middleware("http")et comprendre le rôle decall_next - Ajouter un identifiant de requête et mesurer un temps de traitement de façon transversale
- Structurer un middleware avec état via
BaseHTTPMiddleware - Comprendre l'ordre d'exécution des middlewares (le dernier ajouté est le premier exécuté)
- Capturer une exception non gérée au niveau middleware avant qu'elle ne remonte trop loin
Dans quel contexte ?
L'équipe SRE demande d'ajouter un identifiant unique à chaque requête (X-Request-ID) pour pouvoir corréler les logs entre le frontend, l'API et la base de données lors d'un incident en production. Ajouter ce code dans chacun des 60 endpoints de app/routers/ serait une répétition massive et une source d'oubli à chaque nouvelle route. Un unique middleware add_request_id, appliqué automatiquement à toutes les requêtes, résout ce besoin transversal en une dizaine de lignes.
Une image pour commencer
Imagine une chaîne de contrôles successifs qu'une requête doit traverser avant d'atteindre l'endpoint, puis à nouveau en sens inverse pour la réponse. Un peu comme les étapes de sécurité successives dans un aéroport.
Un middleware s'intercale dans ce trajet. Il peut inspecter, modifier, ou même court-circuiter la requête et la réponse, de façon transversale à toutes les routes de l'application.
Pourquoi ne pas simplement dupliquer ce code dans chaque endpoint ? Ajouter un identifiant unique de requête ou mesurer le temps de traitement concerne TOUTES les routes, pas une en particulier.
Écrire ce code dans chaque endpoint serait une répétition massive et une source d'oubli. Un middleware centralise cette logique transversale à un seul endroit, appliqué automatiquement partout.
Une fois ce rôle compris, un point clé mérite attention : call_next. Chaque middleware reçoit la requête ET une fonction qui représente "la suite de la chaîne".
Le code écrit AVANT await call_next(request) s'exécute à l'aller. Celui écrit APRÈS s'exécute au retour, une fois la réponse déjà construite — ce qui permet, par exemple, d'ajouter un header basé sur le temps réellement mesuré.
Il reste un détail contre-intuitif à connaître : l'ordre d'exécution. Le DERNIER middleware ajouté avec add_middleware est en réalité le PREMIER exécuté sur une requête entrante.
Et il est le dernier sur la réponse sortante, l'inverse de ce qu'on pourrait intuitivement penser. Ce détail compte quand plusieurs middlewares interagissent, par exemple un middleware de compression qui doit s'appliquer après tous les autres.
Ordre d'ajout (add_middleware) | Ordre d'exécution sur la requête entrante | Ordre d'exécution sur la réponse sortante |
|---|---|---|
1er ajouté (RateLimitMiddleware) | 2e exécuté | 1er exécuté |
2e ajouté (GZipMiddleware) | 1er exécuté | 2e exécuté |
Piège fréquent
Un compteur de rate limiting stocké dans un simple dictionnaire Python fonctionne pour une seule instance de serveur, mais devient incohérent dès que l'application tourne sur plusieurs processus (Gunicorn avec plusieurs workers, plusieurs conteneurs) — un problème qui reviendra dans la leçon dédiée à la sécurité, résolu avec un store partagé comme Redis.
Commandes & code
Middlewares custom
from fastapi import FastAPI, Request
import time
import uuid
import logging
app = FastAPI()
logger = logging.getLogger("app")
@app.middleware("http")
async def add_request_id(request: Request, call_next):
request_id = str(uuid.uuid4())
request.state.request_id = request_id # accessible dans les endpoints via request.state
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response# Middleware de logging avec timing — observabilité de base
@app.middleware("http")
async def log_requests(request: Request, call_next):
start_time = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start_time) * 1000
logger.info(
"%s %s -> %d (%.2fms)",
request.method, request.url.path, response.status_code, duration_ms,
)
response.headers["X-Process-Time-Ms"] = f"{duration_ms:.2f}"
return response# BaseHTTPMiddleware — classe plutôt que décorateur, pour un middleware plus structuré
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp, max_requests: int = 100, window_seconds: int = 60):
super().__init__(app)
self.max_requests = max_requests
self.window_seconds = window_seconds
self.requests: dict[str, list[float]] = {}
async def dispatch(self, request: Request, call_next):
client_ip = request.client.host
now = time.time()
history = self.requests.setdefault(client_ip, [])
history[:] = [t for t in history if now - t < self.window_seconds] # purge les anciennes entrées
if len(history) >= self.max_requests:
from starlette.responses import JSONResponse
return JSONResponse({"error": "Trop de requêtes"}, status_code=429)
history.append(now)
return await call_next(request)
app.add_middleware(RateLimitMiddleware, max_requests=100, window_seconds=60)# Ordre d'exécution des middlewares — le dernier ajouté est le PREMIER exécuté
app.add_middleware(RateLimitMiddleware) # exécuté en 2e (plus proche de l'endpoint)
app.add_middleware(GZipMiddleware, minimum_size=1000) # exécuté en 1er (plus externe)
# Requête entrante : GZip -> RateLimit -> endpoint
# Réponse sortante : endpoint -> RateLimit -> GZip# Gestion d'exception au sein d'un middleware — capturer AVANT que ça remonte trop loin
@app.middleware("http")
async def catch_exceptions_middleware(request: Request, call_next):
try:
return await call_next(request)
except Exception as exc:
logger.exception("Exception non gérée dans le middleware")
from starlette.responses import JSONResponse
return JSONResponse({"error": "Erreur interne"}, status_code=500)Résumé
@app.middleware("http")enveloppe chaque requête, utile pour logging, timing, headers custom.BaseHTTPMiddlewareen classe structure mieux un middleware avec état (ex. compteur de rate limiting).- L'ordre de déclaration des middlewares compte : le dernier ajouté s'exécute en premier sur la requête entrante.
- Pour du rate limiting en production multi-instances, préférer un store partagé (Redis) à un dict en mémoire locale.
Exercices pratiques
Mission : le rate limiting qui laisse tout passer en production
Objectif : Corriger un middleware de rate limiting en mémoire locale devenu inefficace multi-instances, et fixer un ordre de middlewares incorrect.
Contexte
RateLimitMiddleware de app/main.py stocke ses compteurs dans un simple dict Python et fonctionnait parfaitement en développement. Depuis le déploiement sur trois workers Gunicorn, un attaquant peut envoyer trois fois plus de requêtes que la limite annoncée sans jamais être bloqué. Par ailleurs, l'ordre d'ajout des middlewares (RateLimitMiddleware puis GZipMiddleware) ne produit pas l'ordre d'exécution attendu par l'équipe.