Les applications web modernes et les architectures d’API distribuées sont soumises à une volatilité permanente de leur trafic. Qu’il s’agisse de pics d’affluence soudains, de robots d’indexation agressifs, de boucles de relance automatisées ou d’attaques par déni de service (DoS), une API dépourvue de politique de régulation de flux constitue une vulnérabilité opérationnelle critique.
La limitation de débit (Rate Limiting) et la régulation de cadence (Throttling) constituent les fondations de la résilience système. Si le rate limiting contrôle le nombre maximal de requêtes qu’un client peut transmettre au cours d’une fenêtre temporelle donnée, le throttling régule activement la cadence de traitement afin de préserver les bases de données sous-jacentes et de garantir l’équité des ressources partagées.
Dans ce dossier technique, nous analysons les modèles mathématiques des principaux algorithmes de limitation de trafic et implémentons un moteur Token Bucket atomique et performant sur Redis.
Comparatif des Algorithmes Clés de Limitation de Débit
Chaque algorithme propose un compromis distinct entre complexité algorithmique, empreinte mémoire et tolérance aux rafales (bursts).
+---------------------------+-------------------+--------------------+------------------------+
| Algorithme | Empreinte Mémoire | Tolérance Rafales | Précision aux Limites |
+---------------------------+-------------------+--------------------+------------------------+
| Fenêtre Fixe (Fixed) | O(1) par client | Vulnérable pic 2x | Imprécis aux frontières|
| Journal Glissant (Log) | O(N) requêtes | Exact et continu | Précision mathématique |
| Compteur Glissant | O(1) par client | Lissage progressif | Approximation (~99%) |
| Seau à Jetons (Token) | O(1) par client | Rafale jusqu'à C | Réapprovisionnement fin|
| Seau Percé (Leaky) | O(N) profondeur | Débit de sortie fixe| Rejet en cas de débordement|
+---------------------------+-------------------+--------------------+------------------------+
1. Compteur à Fenêtre Fixe (Fixed Window Counter)
Le temps est découpé en tranches statiques (par exemple, 60 secondes). Dès que le compteur dépasse le seuil $L$, les requêtes suivantes sont rejetées.
- Vulnérabilité de frontière : Si un client envoie 100 requêtes à la seconde 59 puis 100 autres à la seconde 61, le serveur absorbe 200 requêtes en 2 secondes, doublant la capacité admissible et risquant de saturer les services avals.
2. Seau à Jetons (Token Bucket)
Standard incontournable des passerelles de production (Cloudflare, AWS API Gateway). Un seau d’une contenance $C$ est réapprovisionné à une cadence constante $R$ (jetons/seconde). Chaque requête entrante consomme un jeton. Lorsque le seau est vide, le serveur renvoie une erreur HTTP 429 Too Many Requests. Ce mécanisme permet d’absorber des rafales ponctuelles jusqu’à $C$ tout en garantissant un débit moyen plafonné à $R$.
Implémentation de Production : Token Bucket Atomique avec Redis et Lua
Dans un environnement distribué où plusieurs instances de serveurs sans état coexistent, tenir un décompte en mémoire locale conduit à fragmenter les quotas. Centraliser l’état dans Redis s’impose, mais les opérations classiques de lecture-modification-écriture introduisent des conditions de concurrence (Race Conditions).
Pour garantir l’atomicité de l’évaluation, la logique de réapprovisionnement et de consommation est encapsulée dans un script Lua Redis :
-- KEYS[1]: Clé de limitation (ex: "ratelimit:user_1024")
-- ARGV[1]: Capacité du seau (limite de rafale)
-- ARGV[2]: Vitesse de réapprovisionnement par seconde
-- ARGV[3]: Timestamp Unix actuel (secondes avec décimales)
-- ARGV[4]: Jetons demandés (généralement 1)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local data = redis.call("HMGET", key, "tokens", "last_updated")
local tokens = tonumber(data[1])
local last_updated = tonumber(data[2])
if tokens == nil then
tokens = capacity
last_updated = now
else
local elapsed = math.max(0, now - last_updated)
tokens = math.min(capacity, tokens + (elapsed * refill_rate))
last_updated = now
end
if tokens >= requested then
tokens = tokens - requested
redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated)
local ttl = math.ceil(capacity / refill_rate) * 2
redis.call("EXPIRE", key, ttl)
return {1, math.floor(tokens)} -- Accepté : [1, jetons restants]
else
redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated)
return {0, math.floor(tokens)} -- Rejeté : [0, jetons restants]
end
Intégration Middleware TypeScript
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
export interface RateLimitResult {
allowed: boolean;
remainingTokens: number;
retryAfterSeconds?: number;
}
export async function checkRateLimit(
clientId: string,
capacity: number = 60,
refillRate: number = 1.0 // 1 jeton/sec = 60 requêtes/min
): Promise<RateLimitResult> {
const key = `ratelimit:${clientId}`;
const now = Date.now() / 1000;
const [allowed, remaining] = (await redis.evalSha(LUA_SCRIPT_SHA, {
keys: [key],
arguments: [
capacity.toString(),
refillRate.toString(),
now.toString(),
'1'
],
})) as [number, number];
if (allowed === 1) {
return { allowed: true, remainingTokens: remaining };
}
const retryAfter = Math.ceil((1 - remaining) / refillRate);
return {
allowed: false,
remainingTokens: 0,
retryAfterSeconds: Math.max(1, retryAfter),
};
}
En-têtes HTTP Standardisés
Pour éviter les boucles d’interrogation répétitives des clients, communiquez l’état du quota via les en-têtes standardisés de l’IETF :
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 12
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 12
{
"error": "rate_limit_exceeded",
"message": "Quota d'appels atteint. Veuillez temporiser vos requêtes.",
"retry_after_seconds": 12
}