Rate Limiting — protéger les ressources
Sans rate limiting, un client malveillant (ou un bug) peut saturer ton API en quelques secondes. Voici les algorithmes qui l'en empêchent.
Imagine un restaurant sans file d'attente ni maître d'hôtel. N'importe qui peut entrer, occuper toutes les tables, et empêcher les vrais clients de s'asseoir. Une API sans rate limiting, c'est exactement ça — un seul appelant mal configuré peut rendre le service indisponible pour tout le monde.
Mécanisme qui contrôle le nombre de requêtes qu'un client peut envoyer dans une période donnée. Dépasse la limite → la requête est rejetée (HTTP 429) ou mise en file d'attente.
Quatre algorithmes dominent le domaine. Chacun fait un compromis différent :
| Algorithme | Burst | Débit sortie | Complexité | Cas d'usage typique |
|---|---|---|---|---|
| Token Bucket | Oui | Variable | O(1) | API Gateway, Nginx burst |
| Leaky Bucket | Non | Constant | O(1) | Traffic shaping réseau |
| Fixed Window | Partiel | Variable | O(1) | Compteurs Redis simples |
| Sliding Window | Non | Lissé | O(1) approx. | APIs REST en production |
⬇ Simulateur — charge le module JavaScript ci-dessous
Token Bucket — les rafales autorisées
Le bucket se remplit lentement mais accepte les rafales tant qu'il est plein — débit moyen limité, pic instantané autorisé.
Imagine un seau (bucket) que tu remplis de jetons (tokens) à vitesse constante. Chaque requête consomme un jeton. Si le seau est vide, la requête est rejetée. Si le seau est plein depuis un moment, tu peux envoyer une rafale de requêtes d'un coup — c'est le burst.
Un bucket de capacité N est rempli à vitesse r tokens/seconde. Chaque requête consomme k tokens (généralement 1). Tant que le bucket contient suffisamment de tokens, la requête passe. Sinon, elle est rejetée (ou mise en attente).
L'algorithme tient en une seule ligne :
// Calcule les tokens disponibles à cet instant $tokens = min($capacity, $tokens + ($now - $lastCheck) * $rate);
Deux paramètres clés :
- capacity — taille maximale du bucket = burst maximum autorisé
- rate — vitesse de remplissage = débit moyen autorisé sur la durée
✓ Acceptée
Implémentations en production : AWS API Gateway, Nginx limit_req avec burst, Redis avec un script Lua pour l'atomicité.
⬇ Simulateur — Token Bucket interactif
Leaky Bucket — débit de sortie constant
Le bucket se vide à vitesse constante — toute requête excessive attend. Idéal pour le traffic shaping.
Imagine un seau percé au fond. Tu peux y verser de l'eau (des requêtes) à n'importe quelle vitesse — mais elle ne ressort que goutte à goutte, à débit constant. Si tu verses trop vite, le seau déborde et l'eau est perdue.
Les requêtes entrent dans une file (le bucket, capacité N). Le système les traite à vitesse constante r requêtes/seconde. Si la file est pleine, les nouvelles requêtes sont rejetées. Le débit de sortie est toujours constant — indépendamment du débit d'entrée.
Différence fondamentale avec Token Bucket :
- Débit de sortie : variable (burst possible)
- Régule : le débit moyen sur la durée
- Rafales : autorisées si bucket plein
- Débit de sortie : strictement constant
- Régule : le débit instantané de sortie
- Rafales : absorbées en queue, jamais en burst
En pratique, Nginx limit_req sans le paramètre burst se comporte comme un leaky bucket strict — chaque requête doit attendre son tour :
# Leaky bucket strict — 10 req/s max, aucun burst limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; limit_req zone=api; # sans burst → rejet immédiat si > 10r/s
La différence entre les deux algorithmes est souvent mal comprise. En résumé : Token Bucket contrôle ce qui entre dans le système ; Leaky Bucket contrôle ce qui en sort.
Fixed Window — le compteur simple
Un compteur par fenêtre de temps. Simple, efficace, mais vulnérable au problème de bord aux changements de fenêtre.
Le plus simple à comprendre et à implémenter : divise le temps en fenêtres fixes (ex. chaque minute), maintiens un compteur par fenêtre, et rejette les requêtes quand le compteur atteint la limite.
L'axe du temps est découpé en intervalles fixes (ex. 0–60s, 60–120s…). Un compteur est incrémenté pour chaque requête dans l'intervalle courant. Au début d'une nouvelle fenêtre, le compteur est remis à zéro.
Implémentation Redis en deux commandes :
-- Clé basée sur la fenêtre de temps (minute courante) local key = "rate:" .. client_id .. ":" .. math.floor(tonumber(redis.call("TIME")[1]) / 60) -- Incrémenter et poser une TTL si première requête de la fenêtre local count = redis.call("INCR", key) if tonumber(count) == 1 then redis.call("EXPIRE", key, 60) end return count
Simple et efficace — mais il souffre d'un problème de bord critique :
t=0–60s
99 req à t=59s
t=60–120s
99 req à t=61s
Ce problème est inhérent à l'algorithme. Si ton API peut tolérer ce double pic aux changements de fenêtre, Fixed Window est un excellent choix — sinon, utilise Sliding Window.
Sliding Window — la fenêtre glissante
La fenêtre se déplace avec le temps — pas de reset brutal, pas de problème de bord. L'approximation sliding window counter est la plus utilisée en production.
Sliding Window résout le problème de bord de Fixed Window en faisant glisser la fenêtre avec le temps, plutôt que de la réinitialiser brutalement. Deux variantes existent, avec un compromis mémoire/précision.
Stocke le timestamp de chaque requête (dans un Redis ZSET par exemple). Pour chaque nouvelle requête, compte les entrées dans
[now - window, now]. Pas de problème de bord. Coût : mémoire proportionnelle au nombre de requêtes (O(n) par client).
Combine deux fenêtres fixes consécutives avec interpolation linéaire. La formule :
count = current_window_count + previous_window_count × (1 - elapsed/window_size). Approximation : ±0,3 % d'erreur typique. Coût : O(1) espace, O(1) temps — deux clés Redis.
Pourquoi l'approximation est-elle acceptable ? Parce que 0,3 % d'erreur signifie qu'avec une limite de 1000 req/min, on peut dans le pire cas autoriser 1003 requêtes. C'est négligeable pour la quasi-totalité des APIs.
Implémentation de l'approximation en pseudo-code :
// Deux clés : fenêtre courante et précédente $windowSize = 60; // secondes $now = time(); $currentKey = "rate:{$client}:" . floor($now / $windowSize); $previousKey = "rate:{$client}:" . (floor($now / $windowSize) - 1); $elapsed = $now % $windowSize; // secondes écoulées dans la fenêtre courante $weight = 1 - ($elapsed / $windowSize); $count = $currentCount + ($previousCount * $weight); if ($count >= $limit) { // Rejeter — HTTP 429 }
Headers HTTP et 429
X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After — les headers qui informent le client de sa limite et de son droit restant.
Un bon rate limiter ne se contente pas de rejeter les requêtes — il communique avec le client pour lui permettre d'adapter son comportement. Les headers HTTP standardisent cette communication.
Ensemble de headers HTTP (souvent
X-RateLimit-*) renvoyés par le serveur pour informer le client de sa limite, de son crédit restant, et du moment où il peut reprendre.
Les trois headers fondamentaux :
# En-têtes renvoyés sur chaque réponse X-RateLimit-Limit: 100 # limite totale de la fenêtre X-RateLimit-Remaining: 42 # requêtes restantes dans la fenêtre X-RateLimit-Reset: 1735689600 # timestamp Unix du prochain reset
Quand la limite est atteinte, le serveur répond avec HTTP 429 Too Many Requests et ajoute :
HTTP/1.1 429 Too Many Requests Retry-After: 30 # attendre 30 secondes avant de réessayer X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1735689600
Retry-After et réessaye immédiatement aggrave la situation.
Exemples de limites réelles :
| Service | Limite | Fenêtre | Notes |
|---|---|---|---|
| GitHub API | 5 000 req | 1 heure | Authentifié ; 60/h anonyme |
| Stripe | 100 req/s | — | Par clé API |
| AWS API Gateway | 10 000 req/s | — | Token bucket, burst 5 000 |
X- : RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. Certaines APIs modernes l'utilisent déjà.
Rate Limiting distribué — Redis comme source de vérité
Avec plusieurs serveurs, chaque compteur local est faux. Redis centralisé + atomicité = le seul vrai rate limiter distribué.
Un seul serveur et un compteur en mémoire locale — ça fonctionne. Mais avec deux serveurs derrière un load balancer, chaque serveur compte indépendamment : chacun pense que le client n'a consommé que la moitié des requêtes. La limite effective est multipliée par le nombre de serveurs.
Dans un environnement multi-serveurs, les compteurs locaux ne suffisent pas. Il faut une source de vérité centralisée — Redis est le choix standard. Chaque serveur lit et écrit dans Redis pour obtenir le compte réel et global d'un client.
+1 req
Compteur global
+1 req
L'atomicité est critique. Sans elle, deux serveurs peuvent lire le même compteur, incrémenter chacun de leur côté, et écrire la même valeur — une requête est comptée deux fois en une. Redis fournit deux approches :
Approche 1 — INCR + EXPIRE (simple)
-- Script Lua exécuté atomiquement côté Redis local count = redis.call('INCR', KEYS[1]) if tonumber(count) == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return count
Approche 2 — Token Bucket avec Lua (atomique)
-- Lecture + mise à jour en une seule opération atomique local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens')) or tonumber(ARGV[2]) local lastTime = tonumber(redis.call('HGET', KEYS[1], 'ts')) or tonumber(ARGV[3]) local now = tonumber(ARGV[3]) local rate = tonumber(ARGV[1]) local capacity = tonumber(ARGV[2]) tokens = math.min(capacity, tokens + (now - lastTime) * rate) if tokens >= 1 then tokens = tokens - 1 redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now) redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate)) return 1 -- acceptée else return 0 -- rejetée end
Nginx — limit_req et burst
limit_req_zone, limit_req, burst, nodelay — configurer le rate limiting directement dans Nginx, avant que la requête atteigne l'application.
Placer le rate limiting dans Nginx présente un avantage décisif : la requête est rejetée avant même d'atteindre PHP ou Node.js. Zéro consommation de ressources applicatives pour les requêtes bloquées.
Directive Nginx qui définit une zone partagée en mémoire pour stocker les compteurs de rate limiting. La clé détermine comment les clients sont identifiés (généralement par IP).
Configuration complète avec burst :
# nginx.conf — zone partagée (10 Mo ≈ 160 000 adresses IP) limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; server { location /api/ { # burst=20 : file d'attente de 20 requêtes # nodelay : les requêtes en burst sont traitées immédiatement # (pas d'ajout d'un délai artificiel) limit_req zone=api burst=20 nodelay; # Code HTTP renvoyé pour les requêtes rejetées (défaut : 503) limit_req_status 429; } }
Le paramètre burst implémente une file d'attente de style Token Bucket :
| Comportement | Sans burst | Avec burst | Avec burst + nodelay |
|---|---|---|---|
| Rafale de 25 req | 10 passent, 15 rejetées | 10 traitées, 15 en attente | 30 passent immédiatement |
| Latence ajoutée | Aucune (rejet) | Oui (file d'attente) | Aucune (rejet des excédents) |
| Requêtes perdues | Oui (au-delà de 10/s) | Oui (au-delà de burst+rate) | Oui (au-delà de burst+rate) |
login à 1r/s pour l'endpoint de connexion (anti-brute-force), zone api à 100r/s pour les autres.
# Anti-brute-force sur l'endpoint de login limit_req_zone $binary_remote_addr zone=login:1m rate=1r/s; location /api/login { limit_req zone=login burst=5 nodelay; limit_req_status 429; }
Symfony RateLimiterFactory — intégration pratique
framework.rate_limiter → RateLimiterFactory → consume() → isAccepted() — le rate limiting en 5 lignes.
Symfony embarque un composant de rate limiting dans son framework depuis la version 5.2. Il est basé sur les mêmes algorithmes qu'on a vus, configuré en YAML, et injecté via RateLimiterFactory.
Service Symfony qui crée des instances de
RateLimiter pour une clé donnée (ex. adresse IP, identifiant utilisateur). Chaque appel à consume() décrémente le crédit et retourne un objet RateLimit qui dit si la requête est acceptée.
Configuration dans config/packages/framework.yaml :
# config/packages/framework.yaml framework: rate_limiter: api_anonymous: policy: sliding_window limit: 100 interval: '1 minute' api_authenticated: policy: token_bucket limit: 5000 rate: { interval: '1 hour' }
Utilisation dans un contrôleur Symfony :
use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; final class ApiController { public function __construct( private readonly RateLimiterFactory $apiAnonymousLimiter, ) {} public function __invoke(Request $request): JsonResponse { // Crée ou récupère le limiter pour cette IP $limiter = $this->apiAnonymousLimiter->create($request->getClientIp()); // Consomme 1 token — retourne un RateLimit $rateLimit = $limiter->consume(1); if (!$rateLimit->isAccepted()) { return new JsonResponse( ['error' => 'Too Many Requests'], 429, [ 'X-RateLimit-Remaining' => $rateLimit->getRemainingTokens(), 'X-RateLimit-Limit' => $rateLimit->getLimit(), 'Retry-After' => $rateLimit->getRetryAfter()->getTimestamp() - time(), ] ); } // Requête acceptée — traitement normal return new JsonResponse(['status' => 'ok']); } }
fixed_window, sliding_window, token_bucket. Symfony utilise Redis (ou le cache configuré) comme stockage. Injecte $apiAnonymousLimiter en nommant le service [nom_du_limiter].limiter dans le constructeur — Symfony l'auto-wire.
Le composant gère aussi les décisions préalables avec peek() (vérifie sans consommer) et reset() (réinitialise manuellement le compteur d'un client).
Synthèse — choisir l'algorithme
Token bucket ou sliding window ? Redis ou Nginx ? Le guide de choix selon le cas d'usage.
Quatre algorithmes, deux niveaux d'implémentation (applicatif et infrastructure), et une question centrale : lequel choisir ? Voici le guide de décision.
Choix de l'algorithme
| Cas d'usage | Algorithme recommandé | Raison |
|---|---|---|
| API publique (REST) | Sliding Window Counter | Pas de bord, O(1), approximation acceptable |
| API avec pics légitimes | Token Bucket | Burst autorisé, débit moyen contrôlé |
| Traffic shaping réseau | Leaky Bucket | Débit sortie constant, prévisible |
| Compteur simple (faible trafic) | Fixed Window | Implémentation triviale, problème de bord acceptable |
| Anti-brute-force (login) | Fixed Window strict | 1 req/s max, simplicité prime sur la précision |
Choix du niveau d'implémentation
- Requête bloquée avant PHP — zéro overhead applicatif
- Idéal pour protéger des endpoints exposés (login, upload)
- Limité : pas de contexte utilisateur authentifié
- Basé sur l'IP — contournable avec proxies
- Accès au contexte : utilisateur, rôle, plan tarifaire
- Limites différenciées par client (free vs premium)
- Headers
X-RateLimit-*renvoyés facilement - Coût : une requête Redis par appel API
Token Bucket : capacité + rate → burst autorisé, débit moyen limité.
Leaky Bucket : file d'attente + vidage constant → débit sortie strict.
Fixed Window : compteur par fenêtre → simple, problème de bord aux resets.
Sliding Window : interpolation sur deux fenêtres → précis, O(1), no bord.
Distribué : Redis + Lua → atomicité garantie sur N serveurs.
HTTP 429 +
Retry-After + X-RateLimit-* : communication client/serveur.