Aller au contenu principal

Middleware avec FastAPI

remarque

Ce chapitre vient après Gestion d’erreurs avec FastAPI. Il montre comment ajouter une couche transversale qui s’exécute automatiquement autour de toutes les requêtes HTTP.

Introduction

Jusqu’ici, on a appris à :

  • construire une API de base,
  • ajouter l’authentification,
  • relier les données aux utilisateurs,
  • centraliser la gestion d’erreurs.

Il manque maintenant une autre brique importante de maturité :

la capacité à observer et enrichir toutes les requêtes sans copier du code dans chaque route.

C’est exactement le rôle du middleware.

Ce que ce chapitre va accomplir

À la fin de ce chapitre, tu auras :

  • un fichier core/middleware.py,
  • un middleware HTTP branché dans main.py,
  • un request_id généré automatiquement,
  • un header X-Request-ID,
  • un header X-Process-Time,
  • un logging propre des appels HTTP,
  • une compréhension claire de la différence entre :
    • route,
    • dépendance,
    • handler d’erreur,
    • middleware.

Pourquoi ce chapitre vient maintenant

Le middleware devient vraiment intéressant quand ton API commence à avoir :

  • plusieurs routes,
  • de l’authentification,
  • des erreurs métier,
  • des comportements globaux qu’on ne veut pas répéter.

Par exemple, tu peux vouloir :

  • mesurer le temps de traitement,
  • ajouter un identifiant de requête,
  • logger les appels,
  • enrichir les réponses avec des headers,
  • préparer une future observabilité plus avancée.

C’est exactement le bon moment pour l’introduire.

Source d’inspiration réelle

Ce chapitre s’appuie principalement sur :

  • 27-Middleware/135-main.py

Ce que la source montre :

  • une mesure du temps de traitement avec perf_counter,
  • un middleware HTTP,
  • l’idée d’un logging automatique des requêtes.

Choix pédagogique de ce chapitre

La source a une bonne intuition :

  • mesurer le temps,
  • intercepter globalement les appels,
  • journaliser le résultat.

Mais pour notre wiki, on va faire une version un peu plus propre et plus réutilisable :

  • on garde l’idée du timing,
  • on garde l’idée du logging,
  • on ajoute un request_id,
  • on sort le middleware de main.py pour le placer dans core/middleware.py.

Pourquoi ? Parce que cela rend l’architecture plus lisible.

La vraie idée à comprendre

Un middleware n’est pas une route. Un middleware n’est pas non plus une dépendance métier.

Un middleware est une couche transversale qui s’exécute :

  1. avant que la requête n’atteigne la route,
  2. puis après que la route a produit une réponse.

Autrement dit, c’est un endroit idéal pour gérer les préoccupations globales.

Ce qu’un middleware fait bien

Un middleware est très adapté pour :

  • logging global,
  • timing,
  • ajout de headers,
  • request ID,
  • observabilité,
  • parfois CORS ou proxy handling via d’autres middlewares spécialisés.

Ce qu’un middleware ne doit pas porter seul

Un middleware n’est pas le meilleur endroit pour :

  • la logique métier métier pure,
  • la validation métier fine,
  • les permissions détaillées par ressource,
  • les règles de domaine complexes.

Ces choses vivent mieux dans :

  • les services,
  • les dépendances,
  • ou les handlers d’erreur.

Différence entre middleware, dépendance et handler d’erreur

Dépendance

Elle sert à fournir quelque chose à une route. Exemple : l’utilisateur courant, une session DB, un service.

Handler d’erreur

Il sert à transformer une exception en réponse API cohérente.

Middleware

Il sert à envelopper tout le cycle HTTP. Exemple : mesurer le temps total, logger, ajouter des headers.

Architecture visée après ce chapitre

On ajoute un nouveau fichier :

fastapi-base-api/
├── main.py
├── config.py
├── requirements.txt
├── .gitignore
├── .env
├── .env.example
├── core/
│ ├── __init__.py
│ ├── security.py
│ ├── exceptions.py
│ └── middleware.py
├── api/
│ ├── __init__.py
│ ├── router.py
│ ├── dependencies.py
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── task.py
│ │ └── user.py
│ └── schemas/
│ ├── __init__.py
│ ├── task.py
│ └── user.py
├── database/
│ ├── __init__.py
│ ├── models.py
│ └── session.py
└── services/
├── __init__.py
├── task.py
└── user.py

Repères utiles avant le code

Avant de coder, retiens ceci :

  • le middleware voit toutes les requêtes,
  • il peut modifier la réponse,
  • il peut écrire dans request.state,
  • il peut ajouter des headers,
  • il ne remplace pas les handlers d’erreur,
  • il complète la couche de robustesse.

Étape 1 — créer core/middleware.py

On va écrire un middleware simple et utile :

  • il génère un request_id,
  • il mesure le temps de traitement,
  • il ajoute deux headers à la réponse,
  • il écrit un log lisible.
import logging
from time import perf_counter
from uuid import uuid4

from fastapi import Request, Response


logger = logging.getLogger("task_api.middleware")

if not logger.handlers:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)


async def request_metrics_middleware(request: Request, call_next):
request_id = str(uuid4())
request.state.request_id = request_id

start = perf_counter()

try:
response: Response = await call_next(request)
except Exception:
process_time = round(perf_counter() - start, 4)
logger.exception(
"%s %s -> unhandled exception (%.4fs) [request_id=%s]",
request.method,
request.url.path,
process_time,
request_id,
)
raise

process_time = round(perf_counter() - start, 4)

response.headers["X-Request-ID"] = request_id
response.headers["X-Process-Time"] = str(process_time)

logger.info(
"%s %s -> %s (%.4fs) [request_id=%s]",
request.method,
request.url.path,
response.status_code,
process_time,
request_id,
)

return response

Pourquoi cette version est bonne pédagogiquement

Parce qu’elle montre quatre idées importantes d’un coup :

  1. le middleware voit la requête avant et après la route
  2. request.state permet de stocker des infos utiles pendant le cycle de vie de la requête
  3. la réponse peut être enrichie avec des headers
  4. le log peut être centralisé sans polluer les routes

Étape 2 — brancher le middleware dans main.py

Maintenant, on l’enregistre dans l’application.

from contextlib import asynccontextmanager

from fastapi import FastAPI
from scalar_fastapi import get_scalar_api_reference

from api.router import router
from core.exceptions import add_exception_handlers
from core.middleware import request_metrics_middleware
from database.session import create_db_tables


@asynccontextmanager
async def lifespan(app: FastAPI):
await create_db_tables()
yield


app = FastAPI(
title="Task Organizer API",
description="Une API FastAPI avec middleware HTTP",
lifespan=lifespan,
)

add_exception_handlers(app)
app.middleware("http")(request_metrics_middleware)
app.include_router(router)


@app.get("/scalar", include_in_schema=False)
def get_scalar_docs():
return get_scalar_api_reference(
openapi_url=app.openapi_url,
title="Scalar API",
)

Ce qu’il faut remarquer

La ligne la plus importante est :

app.middleware("http")(request_metrics_middleware)

C’est elle qui dit à FastAPI :

  • applique cette fonction à tout le trafic HTTP.

Étape 3 — améliorer l’intégration avec la gestion d’erreurs

Le middleware crée un request_id.

C’est très utile si les réponses d’erreur peuvent aussi renvoyer cet identifiant. Ainsi, côté client ou dans les logs, on peut rattacher un problème à une requête précise.

Tu peux donc enrichir core/exceptions.py comme ceci :

def _build_handler(exception_class: type[AppError]):
async def handler(request: Request, exc: AppError):
content = {"detail": exception_class.detail}

request_id = getattr(request.state, "request_id", None)
if request_id is not None:
content["request_id"] = request_id

return JSONResponse(
status_code=exception_class.status_code,
content=content,
)

return handler

Pourquoi c’est utile

Quand une requête échoue, le client peut recevoir quelque chose comme :

{
"detail": "Task not found",
"request_id": "3de3d824-7d5f-48d4-a1d9-18ac4d3fd31a"
}

Et dans les logs, tu retrouveras ce même request_id.

C’est un très bon premier niveau d’observabilité.

Étape 4 — ne pas déplacer la logique métier dans le middleware

C’est une tentation fréquente.

Exemple d’erreur de design :

  • faire toute l’auth dans le middleware,
  • décider dans le middleware qui peut modifier quelle tâche,
  • mettre des règles métier complexes dans la couche globale.

Pour notre projet, ce n’est pas ce qu’on veut.

On garde :

  • l’auth dans core/security.py + dépendances,
  • les règles métier dans services/,
  • les erreurs dans core/exceptions.py,
  • les comportements transversaux dans core/middleware.py.

Étape 5 — observer le comportement sur une route normale

Relance l’application :

uvicorn main:app --reload

Puis appelle une route simple :

curl -i http://127.0.0.1:8000/scalar

Tu dois voir apparaître dans les headers quelque chose comme :

X-Request-ID: 5dc8f0f2-1f19-4c1a-a861-2d2fa1f2d8ad
X-Process-Time: 0.0041

Étape 6 — observer le comportement sur une route protégée

Appelle une route protégée avec un token valide :

curl -i http://127.0.0.1:8000/task/mine \
-H "Authorization: Bearer $TOKEN_ENES"

Tu dois là aussi voir :

  • X-Request-ID
  • X-Process-Time

Étape 7 — observer le comportement sur une erreur

Appelle volontairement une route qui renverra une erreur gérée :

curl -i "http://127.0.0.1:8000/task/?id=999" \
-H "Authorization: Bearer $TOKEN_ENES"

Si tu as enrichi le handler d’erreur avec request_id, tu devrais obtenir un JSON du style :

{
"detail": "Task not found",
"request_id": "..."
}

Et dans les logs, tu verras aussi l’appel correspondant.

Étape 8 — comprendre le lien avec la source 27-Middleware

La source montrait déjà l’idée essentielle :

  • mesurer le temps,
  • logger les appels,
  • utiliser un middleware HTTP.

Ici, on a gardé cette logique, mais on l’a améliorée pour le wiki :

  • architecture plus propre,
  • middleware externalisé,
  • request_id,
  • intégration plus claire avec le chapitre précédent sur les erreurs.

Étape 9 — penser à l’évolution future

Le middleware qu’on vient d’écrire est volontairement simple.

Mais il ouvre naturellement la porte vers :

  • corrélation de logs,
  • audit trail,
  • métriques,
  • tracing distribué,
  • logs structurés,
  • intégration future avec des outils d’observabilité.

Pièges et erreurs fréquentes

  • mettre de la logique métier dans le middleware
  • croire qu’un middleware remplace les dépendances
  • croire qu’un middleware remplace les handlers d’erreur
  • oublier que le middleware s’exécute sur toutes les routes
  • logger trop d’informations sensibles
  • ajouter trop de complexité trop tôt

Bonnes pratiques à retenir

  • garder le middleware simple et transversal
  • l’utiliser pour les comportements globaux
  • enrichir la réponse avec des headers utiles
  • utiliser request.state pour faire circuler des infos techniques
  • relier le middleware à la gestion d’erreurs pour obtenir une API plus observable

Variantes et évolutions possibles

Après ce chapitre, tu peux aller vers :

  • middleware de sécurité plus spécialisés,
  • logs JSON structurés,
  • ajout d’un request_id dans tous les logs applicatifs,
  • intégration avec Celery et traçabilité des jobs,
  • instrumentation plus avancée.

Checklist de validation

Avant de passer à la suite, vérifie que tu peux :

  • expliquer ce qu’un middleware doit faire et ne pas faire
  • ajouter un request_id sur toutes les requêtes
  • exposer un temps de traitement dans un header
  • utiliser request.state pour faire circuler une information technique
  • expliquer pourquoi la logique métier ne doit pas vivre ici

Résumé final

Dans ce chapitre, tu as appris à ajouter une vraie couche transversale à ton API.

Tu as vu comment :

  • écrire un middleware HTTP,
  • mesurer le temps de traitement,
  • générer un request_id,
  • enrichir les réponses avec des headers,
  • relier le middleware à la gestion d’erreurs.

Si tu retiens une seule chose, retiens celle-ci :

une route gère une action métier ; un middleware observe et enrichit tout le trafic autour de cette action.

Pour aller plus loin