Ajouter un cache à notre application FastAPI avec redis

 

Quand une route fait un calcul coûteux ou interroge un service lent, garder sa réponse quelques secondes évite de refaire le même travail à chaque requête identique. Dans ce tutoriel, nous allons mettre en place ce cache avec la librairie fastapi-cache2, d’abord en mémoire, puis dans Redis pour le partager entre plusieurs processus.

Dans cet article :

  • Installation
  • Cache en mémoire
  • Ce que le décorateur met en cache
  • Cache Redis
  • Personnaliser la clé de cache

Pré-requis : savoir démarrer une API minimale, voir Python : Comment faire une api web avec FastAPI.

Installation

On installe FastAPI, uvicorn, fastapi-cache2 et jinja2, puis le client Redis pour la deuxième partie :

pip install fastapi uvicorn fastapi-cache2 jinja2
# Pour la partie Redis :
pip install redis

Sous Windows (PowerShell), vous pouvez faire :

python -m pip install fastapi uvicorn fastapi-cache2 jinja2
python -m pip install redis

jinja2 n’est pas une dépendance déclarée de fastapi-cache2, mais il est devenu nécessaire : la librairie importe le module de templates de Starlette, le framework sur lequel repose FastAPI. Depuis Starlette 1.0, sortie en mars 2026, ce module refuse de se charger sans jinja2, et l’import de fastapi_cache échoue avec l’erreur ImportError: jinja2 must be installed to use Jinja2Templates. Si vous avez installé FastAPI avec pip install "fastapi[standard]", jinja2 est déjà là.

Pour Redis, on installe le paquet redis à part, plutôt que l’extra fastapi-cache2[redis] : celui-ci impose une version de redis antérieure à la 5, dont le client asynchrone n’a pas la méthode aclose() utilisée plus bas.

fastapi-cache2 n’a pas eu de nouvelle version depuis la 0.2.2, publiée en juillet 2024. Les exemples de cet article ont été testés en octobre 2026 avec fastapi-cache2 0.2.2, FastAPI 0.142.3, redis-py 8.1.0, Redis 7.0 et Python 3.13.

Cache en mémoire

Le backend InMemoryBackend garde les réponses dans la mémoire du processus : il n’y a rien d’autre à installer, c’est le plus simple pour commencer.

# app_memory.py
import asyncio
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi_cache import FastAPICache
from fastapi_cache.backends.inmemory import InMemoryBackend
from fastapi_cache.decorator import cache

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Initialisation du cache côté application (au démarrage)
    FastAPICache.init(InMemoryBackend(), prefix="fastapi-cache")
    yield
    # Rien à nettoyer en fin de vie pour l'in-memory

app = FastAPI(lifespan=lifespan)

@app.get("/")
async def root():
    return {"service": "demo-cache", "backend": "memory"}

@app.get("/slow")
@cache(expire=10)  # la réponse est mise en cache 10 secondes
async def slow_endpoint(q: int = 1):
    # Simule un travail coûteux
    await asyncio.sleep(2)
    return {
        "q": q,
        "ts": datetime.now(timezone.utc).isoformat(timespec="seconds")
    }

# (Optionnel) vider le cache par namespace
@app.delete("/cache/clear")
async def clear_cache(namespace: str | None = None):
    await FastAPICache.clear(namespace=namespace)
    return {"cleared": True, "namespace": namespace}

FastAPICache.init() est appelé au démarrage de l’application, dans la fonction lifespan. Le décorateur @cache(expire=10) garde ensuite la réponse de /slow pendant 10 secondes. Pour simuler un traitement long, cette route attend 2 secondes avant de renvoyer l’heure courante.

On lance l’API :

uvicorn app_memory:app --reload

Le premier appel prend 2 secondes :

curl "http://127.0.0.1:8000/slow?q=42"
{"q":42,"ts":"2026-10-07T20:49:34+00:00"}

Le suivant, s’il arrive dans les 10 secondes, est immédiat et renvoie la même heure. Avec -i, curl affiche aussi les en-têtes de la réponse :

curl -i "http://127.0.0.1:8000/slow?q=42"
HTTP/1.1 200 OK
date: Wed, 07 Oct 2026 20:49:34 GMT
server: uvicorn
content-length: 41
content-type: application/json
cache-control: max-age=9
etag: W/3506403070760895541
x-fastapi-cache: HIT

{"q":42,"ts":"2026-10-07T20:49:34+00:00"}

L’en-tête x-fastapi-cache vaut MISS quand la réponse vient d’être calculée et HIT quand elle sort du cache. fastapi-cache2 ajoute aussi un cache-control avec la durée de vie restante, en secondes, et un ETag.

Ce cache a ses limites : il vit dans la mémoire du processus, il est donc perdu à chaque redémarrage, et il n’est pas partagé. Avec uvicorn --workers 4, chacun des 4 processus (les workers) a son propre cache. De plus, InMemoryBackend ne supprime une entrée expirée que lorsqu’on la relit : une route appelée avec beaucoup de valeurs différentes (une entrée par valeur de q ici) fait grossir la mémoire du processus.

Ce que le décorateur met en cache

L’ordre des décorateurs compte : @cache doit se trouver sous @app.get, pour envelopper la fonction avant que FastAPI ne l’enregistre. Dans l’autre sens, la route fonctionne mais rien n’est mis en cache.

La clé de cache est calculée à partir du module et du nom de la fonction, et des valeurs de ses arguments : /slow?q=42 et /slow?q=43 donnent deux entrées différentes. Ce qui n’est pas un argument de la fonction n’entre pas dans la clé : ni les en-têtes de la requête, ni un paramètre d’URL que la fonction ne déclare pas (/slow?q=42&foo=1 renvoie la réponse en cache de /slow?q=42).

Seules les requêtes GET sont mises en cache. Et le client peut contourner le cache : avec l’en-tête Cache-Control: no-store, la réponse est recalculée sans passer par le cache, et avec Cache-Control: no-cache, elle est recalculée puis remise en cache. N’importe qui peut donc forcer le recalcul d’une route : le cache accélère les réponses, il ne protège pas une route coûteuse contre les abus (c’est le rôle d’un rate limiter).

Enfin, la route DELETE /cache/clear vide le cache : sans namespace, FastAPICache.clear() supprime toutes les clés de l’application. En production, une route comme celle-ci doit être protégée par une authentification, sinon le premier venu peut vider votre cache.

Cache Redis

Avec Redis, le cache vit en dehors de l’application : il est partagé entre tous les workers et toutes les instances de l’API, et il survit à leurs redémarrages.

Démarrer un Redis local (docker-compose)

Le plus simple pour avoir un Redis en local est de passer par Docker, avec ce fichier docker-compose.yml :

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    command: [ "redis-server", "--appendonly", "yes" ]

On démarre Redis :

docker compose up -d

L’option --appendonly yes active la persistance sur disque : le cache survit aussi à un redémarrage de Redis.

Le code avec RedisBackend

# app_redis.py
import asyncio
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend
from fastapi_cache.decorator import cache
import redis.asyncio as redis

redis_client: redis.Redis | None = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global redis_client
    # Connexion Redis (adapter l'URL si besoin, auth, DB, etc.)
    redis_client = redis.from_url(
        "redis://localhost:6379", encoding="utf-8", decode_responses=False
    )
    FastAPICache.init(RedisBackend(redis_client), prefix="fastapi-cache")
    yield
    # Fermeture propre de la connexion Redis
    assert redis_client is not None
    await redis_client.aclose()

app = FastAPI(lifespan=lifespan)

@app.get("/")
async def root():
    return {"service": "demo-cache", "backend": "redis"}

@app.get("/slow")
@cache(expire=15, namespace="v1")  # 15s de cache, dans le namespace v1
async def slow_endpoint(q: int = 1):
    await asyncio.sleep(2)
    return {
        "q": q,
        "ts": datetime.now(timezone.utc).isoformat(timespec="seconds")
    }

@app.delete("/cache/clear")
async def clear_cache(namespace: str | None = None):
    await FastAPICache.clear(namespace=namespace)
    return {"cleared": True, "namespace": namespace}

Par rapport à la version en mémoire, on crée un client Redis asynchrone au démarrage, et on le referme à l’arrêt de l’application. Ce client doit garder decode_responses=False (la valeur par défaut) : fastapi-cache2 stocke des bytes, et un client qui décode les réponses en texte casse le cache. La route /slow est rangée dans le namespace v1, ce qui permet de vider d’un coup toutes les clés de ce groupe.

On lance l’API :

uvicorn app_redis:app --reload

Le premier appel prend 2 secondes, le deuxième est immédiat. Après la purge du namespace v1, la réponse est recalculée, avec une nouvelle heure :

curl "http://127.0.0.1:8000/slow?q=1"
{"q":1,"ts":"2026-10-07T20:49:55+00:00"}
curl "http://127.0.0.1:8000/slow?q=1"
{"q":1,"ts":"2026-10-07T20:49:55+00:00"}
curl -X DELETE "http://127.0.0.1:8000/cache/clear?namespace=v1"
{"cleared":true,"namespace":"v1"}
curl "http://127.0.0.1:8000/slow?q=1"
{"q":1,"ts":"2026-10-07T20:49:57+00:00"}

L’avantage de Redis, c’est qu’on voit ce qu’il contient :

docker compose exec redis redis-cli keys 'fastapi-cache:*'
fastapi-cache:v1:1dfdffccea5a80189660c039da617fcb

La clé est formée du préfixe passé à FastAPICache.init(), du namespace, puis d’un hash MD5 du module, du nom de la fonction et de ses arguments. Avec Redis, choisissez un préfixe explicite (le nom de votre application par exemple) pour ne pas mélanger vos clés avec celles d’une autre application qui utiliserait la même base.

Attention, FastAPICache.clear() s’appuie sur la commande KEYS de Redis, qui parcourt toutes les clés de la base : la documentation de Redis déconseille de l’utiliser en production sur une grosse base, car elle peut dégrader fortement les performances.

Personnaliser la clé de cache

Par défaut, deux utilisateurs qui appellent la même route avec les mêmes paramètres reçoivent la même réponse en cache. Si la réponse dépend d’un en-tête, comme la langue ou l’utilisateur, il faut l’ajouter à la clé avec un key_builder.

Attention à la signature : dans fastapi-cache2 0.2, default_key_builder attend request, response, args et kwargs en arguments nommés uniquement (placés après le *). Le builder personnalisé reprend donc la même signature et transmet ces valeurs par nom. Ajoutez dans app_redis.py :

from fastapi import Request, Response
from fastapi_cache.key_builder import default_key_builder

def user_lang_key_builder(
    func,
    namespace: str = "",
    *,
    request: Request = None,
    response: Response = None,
    args: tuple = (),
    kwargs: dict = None,
) -> str:
    # Repart d'un builder par défaut et y ajoute l'Accept-Language et un user-id (fictif)
    base = default_key_builder(
        func,
        namespace,
        request=request,
        response=response,
        args=args,
        kwargs=kwargs or {},
    )
    lang = request.headers.get("accept-language", "*") if request else "*"
    user = request.headers.get("x-user-id", "anon") if request else "anon"
    return f"{base}:u={user}:lang={lang}"

@app.get("/profil")
@cache(expire=60, namespace="v1", key_builder=user_lang_key_builder)
async def profil():
    return {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds")}

Chaque combinaison utilisateur et langue a maintenant sa propre entrée :

curl -H "X-User-Id: 42" -H "Accept-Language: fr" http://127.0.0.1:8000/profil
curl -H "X-User-Id: 7" -H "Accept-Language: en" http://127.0.0.1:8000/profil
curl http://127.0.0.1:8000/profil

Une fois la clé de /slow expirée, Redis contient :

docker compose exec redis redis-cli keys 'fastapi-cache:*'
fastapi-cache:v1:2420ee266db3d4bd2cf91a64838cb258:u=42:lang=fr
fastapi-cache:v1:2420ee266db3d4bd2cf91a64838cb258:u=7:lang=en
fastapi-cache:v1:2420ee266db3d4bd2cf91a64838cb258:u=anon:lang=*

Ici, l’identifiant de l’utilisateur vient d’un en-tête X-User-Id pour l’exemple. Dans une vraie application, il doit venir de l’authentification : sinon, il suffit de changer l’en-tête pour lire le cache d’un autre utilisateur.

Voir aussi