Construire un middleware de limitation de débit dans Express
Un endpoint d'API non protégé est une invitation : un script client qui se comporte mal, une boucle de nouvelles tentatives sans backoff, un scraper, et votre serveur passe tout son temps à servir la même poignée de clients au détriment de tous les autres. La limitation de débit (rate limiting) règle cela en plafonnant le nombre de requêtes qu'un client peut faire dans une fenêtre donnée, et vous pouvez en construire une solide vous-même en une quarantaine de lignes, sans aucune dépendance.
Ce que signifie réellement « limiter le débit »
Un limiteur de débit suit les requêtes par client (généralement identifié par adresse IP ou clé d'API) et rejette les requêtes dès qu'un client franchit un seuil dans une fenêtre de temps : « 100 requêtes par minute », par exemple. Une fois la fenêtre écoulée, le compteur est réinitialisé et le client peut de nouveau envoyer des requêtes.
L'implémentation correcte la plus simple est le compteur à fenêtre fixe : pour chaque client, on conserve un compteur et l'horodatage de début de fenêtre. On incrémente à chaque requête ; on réinitialise les deux à l'expiration de la fenêtre.
Un limiteur minimal à fenêtre fixe
function createRateLimiter({ windowMs, max }) {
const clients = new Map();
return function rateLimiter(req, res, next) {
const key = req.ip;
const now = Date.now();
const entry = clients.get(key);
if (!entry || now - entry.windowStart >= windowMs) {
clients.set(key, { windowStart: now, count: 1 });
return next();
}
if (entry.count >= max) {
const retryAfterMs = windowMs - (now - entry.windowStart);
res.setHeader('Retry-After', Math.ceil(retryAfterMs / 1000));
return res.status(429).json({ error: 'Too many requests' });
}
entry.count += 1;
next();
};
}
Branchement dans une application Express :
const express = require('express');
const app = express();
const limiter = createRateLimiter({ windowMs: 60_000, max: 100 });
app.use('/api/', limiter);
app.get('/api/posts', (req, res) => {
res.json({ posts: [] });
});
app.listen(3000);
Chaque client obtient 100 requêtes par fenêtre de 60 secondes avant de voir un 429 Too Many Requests, avec un en-tête Retry-After lui indiquant quand revenir.
Le problème de la fenêtre fixe : les rafales aux frontières
Les fenêtres fixes ont un défaut bien connu : un client peut faire max requêtes durant la dernière seconde d'une fenêtre, puis immédiatement max de plus dans la première seconde de la suivante, soit le double du débit prévu, concentré dans une rafale de deux secondes à la frontière.
window 1: [ ......................############] <- 100 requests, last second
window 2: [############......................] <- 100 more, first second
Pour la plupart des API, cela ne pose pas de problème. Sinon, la solution est un journal à fenêtre glissante : au lieu d'un compteur par client, on stocke l'horodatage de chaque requête dans la fenêtre et l'on compte combien tombent dans now - windowMs.
function createSlidingWindowLimiter({ windowMs, max }) {
const clients = new Map();
return function rateLimiter(req, res, next) {
const key = req.ip;
const now = Date.now();
const timestamps = clients.get(key) ?? [];
const windowStart = now - windowMs;
const recent = timestamps.filter((t) => t > windowStart);
if (recent.length >= max) {
clients.set(key, recent);
res.setHeader('Retry-After', Math.ceil((recent[0] - windowStart) / 1000));
return res.status(429).json({ error: 'Too many requests' });
}
recent.push(now);
clients.set(key, recent);
next();
};
}
C'est exact mais plus coûteux en mémoire (un horodatage par requête et par client, et non un compteur). Pour les API à fort trafic, un hybride, le compteur à fenêtre glissante, qui pondère le compteur de la fenêtre précédente selon la part qui chevauche la fenêtre actuelle, donne un comportement proche de l'exactitude au coût mémoire d'une fenêtre fixe. C'est plus de mathématiques que de code, ce qui explique pourquoi la plupart des équipes recourent à une bibliothèque ou à un limiteur au niveau du proxy (limit_req de nginx, ou un limiteur adossé à Redis) dès qu'il leur faut cela à grande échelle.
Choisir une clé de limitation
req.ip est la clé évidente, mais elle ne tient pas derrière un NAT partagé (un bureau plein d'ingénieurs partageant une seule IP publique) ou un proxy qui ne transmet pas l'adresse réelle du client. Deux ajustements comptent en pratique :
- Faites correctement confiance à votre proxy. Derrière nginx ou un répartiteur de charge,
req.ipsera l'adresse du proxy à moins de configurer Express pour lui faire confiance :app.set('trust proxy', 1)et lecture dereq.ipdepuisX-Forwarded-For. Si vous vous trompez, tous les clients sont limités comme un seul. - Préférez une clé d'API à une IP quand vous en avez une. Les endpoints authentifiés doivent limiter le débit par clé d'API ou identifiant utilisateur, pas par IP : c'est l'identité réelle qui vous importe, et elle survit aux changements d'IP et aux réseaux partagés.
Nettoyer les entrées périmées
La Map des deux implémentations ci-dessus grossit indéfiniment si vous n'évincez pas les anciennes entrées : une lente fuite mémoire dans un processus de longue durée. Un balayage périodique simple règle cela :
setInterval(() => {
const cutoff = Date.now() - windowMs;
for (const [key, entry] of clients) {
if (entry.windowStart < cutoff) clients.delete(key);
}
}, windowMs).unref();
Le .unref() compte dans Node : il indique à la boucle d'événements de ne pas maintenir le processus en vie uniquement pour ce minuteur, afin de ne pas gêner l'arrêt propre.
Quand recourir plutôt à une bibliothèque
express-rate-limit implémente tout ce qui précède (plus la gestion d'IPv6 et des magasins interchangeables) et c'est le bon choix par défaut pour une application à processus unique. Dès que vous exécutez plusieurs instances derrière un répartiteur de charge, une Map en mémoire ne fonctionne plus : chaque instance a ses propres compteurs, si bien qu'un client peut obtenir max requêtes par instance au lieu de max au total. Il faut alors un magasin partagé, et express-rate-limit en prend en charge un adossé à Redis précisément pour cette raison.
À retenir
Un limiteur de débit est fondamentalement un compteur auquel on a attaché une fenêtre de temps : tout le reste n'est que raffinement. Commencez par une fenêtre fixe : c'est simple, peu coûteux et suffisamment correct pour la plupart des API. Passez à une fenêtre glissante seulement quand les rafales aux frontières sont un problème réel et mesuré, et passez à un magasin partagé dès que vous exécutez plus d'un processus serveur.