Retour au cours

backend / fastapi

Middlewares custom

Leçon 131 exercice

Explication

Ce que vous allez apprendre

  • Écrire un middleware avec @app.middleware("http") et comprendre le rôle de call_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 entranteOrdre 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

python
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
python
# 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
python
# 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)
python
# 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
python
# 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.
  • BaseHTTPMiddleware en 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

1 disponible
1

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.

Résoudre l’exercice →