← Tous les cours / Rate Limiting Cours
00 — VUE D'ENSEMBLE

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.

Rate Limiting
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 :

AlgorithmeBurstDébit sortieComplexité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 ci-dessous : envoie 10 requêtes simultanées et observe comment chaque algorithme réagit différemment au même pic de charge.

⬇ Simulateur — charge le module JavaScript ci-dessous

01 — TOKEN BUCKET

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.

Token Bucket
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 :

Requête entrante
Token disponible ?
→ oui
Consomme 1 token
✓ Acceptée
Token disponible ?
→ non
✗ Rejetée (429)
⚠️ Burst ≠ faille : le burst est intentionnel. Un client peut légitimement avoir un pic soudain d'activité (import de données, reconnexion après coupure). Token bucket l'autorise, tant que le débit moyen reste sous la limite.

Implémentations en production : AWS API Gateway, Nginx limit_req avec burst, Redis avec un script Lua pour l'atomicité.

⬇ Simulateur — Token Bucket interactif

Tokens disponibles 10 / 10
02 — LEAKY BUCKET

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.

Leaky Bucket
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 :

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
Leaky Bucket
  • Débit de sortie : strictement constant
  • Régule : le débit instantané de sortie
  • Rafales : absorbées en queue, jamais en burst
💡 Usage principal : le traffic shaping réseau. Les équipements réseau (routeurs, pare-feux) utilisent leaky bucket pour garantir un débit sortant stable, quelle que soit la variation du trafic entrant.

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.

03 — FIXED WINDOW

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.

Fixed Window Counter
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 :

⚠️ Problème de bord (boundary problem) : supposons une limite de 100 req/min. Un client envoie 99 requêtes à t=59s (dernière seconde de la fenêtre A), puis 99 requêtes à t=61s (première seconde de la fenêtre B). Les deux fenêtres sont sous la limite — mais en 2 secondes, 198 requêtes ont atteint le serveur, soit presque le double de la limite !
Fenêtre A
t=0–60s
99 req à t=59s
Reset à t=60s
Fenêtre B
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.

04 — 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.

Sliding Window Log
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).
Sliding Window Counter (approximation)
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
}
💡 Le meilleur compromis en production : Sliding Window Counter (approximation) est l'algorithme utilisé par la plupart des implémentations modernes (Cloudflare, Stripe). O(1) mémoire, O(1) temps, pas de problème de bord, erreur négligeable.
05 — HEADERS HTTP

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.

Headers de Rate Limiting
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 : ce header est défini dans la RFC 7231 et fait partie du standard HTTP. Un client bien écrit doit le respecter et ne pas réessayer avant le délai indiqué. Un client qui ignore Retry-After et réessaye immédiatement aggrave la situation.

Exemples de limites réelles :

ServiceLimiteFenêtreNotes
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
💡 Standardisation : le draft IETF RateLimit Header Fields for HTTP propose des noms sans préfixe X- : RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. Certaines APIs modernes l'utilisent déjà.
06 — DISTRIBUÉ

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.

Rate Limiting distribué
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.
Serveur A
+1 req
Redis
Compteur global
Serveur B
+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
⚠️ Sticky sessions ≠ solution : router toutes les requêtes d'un client vers le même serveur (sticky sessions) permet d'utiliser un compteur local, mais c'est fragile — en cas de redémarrage du serveur, toute l'histoire des compteurs est perdue. Redis reste la seule vraie solution.
07 — NGINX

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.

limit_req_zone
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 :

ComportementSans burstAvec burstAvec 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)
💡 Plusieurs zones : tu peux définir des zones différentes selon l'endpoint ou l'utilisateur. Ex. : zone 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;
}
08 — SYMFONY

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.

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']);
    }
}
💡 Stratégies disponibles : 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).

09 — SYNTHÈSE

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'usageAlgorithme 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

Nginx (infrastructure)
  • 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
Application (Symfony + Redis)
  • 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
💡 Les deux en même temps : en production, combiner les deux niveaux. Nginx protège contre les attaques DDoS brutales (IP bannie en amont). Symfony gère les limites métier par utilisateur authentifié. Ce ne sont pas des alternatives — ce sont des couches complémentaires.
Récapitulatif des concepts clés
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.