← Retour au blog
Résilience

Pattern Rate Limiting : protéger le serveur contre la surcharge

Le Rate Limiting pour protéger le serveur : limiter les requêtes pour la stabilité, la sécurité et le business. Ses liens avec Retry et Circuit Breaker, infra et applicatif.

📅 ✍️ Antoine Coulon
resiliencerate-limitingdistributed-systemssecurityhttp

Jusqu’ici, cette série sur la résilience s’est concentrée sur un seul côté de l’équation : protéger le client. Le Retry pour réessayer un appel qui échoue, le Timeout pour borner une attente, le Circuit Breaker pour couper court à l’effet domino, trois patterns qui partent tous du même point de vue, celui de l’application qui consomme un service externe.

Mais un système distribué a deux extrémités. Et le jour où c’est votre service qui se retrouve à l’autre bout du fil, submergé par des requêtes, aucun de ces patterns ne vous sauvera. Pour protéger le serveur, il faut changer de perspective et adopter le pattern qui agit en amont : le Rate Limiting.

Le Rate Limiting, c’est quoi ?

Le principe tient en une phrase : limiter le nombre de requêtes qu’un client peut envoyer dans un laps de temps donné. Au-delà du seuil, le serveur refuse poliment de traiter les requêtes supplémentaires plutôt que de se laisser engloutir.

L’objectif premier est la stabilité. Une ressource serveur (CPU, mémoire, connexions, pool de threads) n’est jamais infinie. En plafonnant le débit entrant, on garantit que le système reste dans une zone de fonctionnement maîtrisée, même face à un pic de trafic ou à un client mal codé qui part en boucle. Mieux vaut servir correctement 95 % des requêtes que de s’effondrer en tentant d’en servir 100 %.

Une dimension sécurité

Le second cas d’usage est la sécurité. Le Rate Limiting est une première ligne de défense contre les abus : brute force sur une page de login, scraping agressif, déni de service (DDoS). En restreignant les requêtes issues d’une source identifiée (typiquement une adresse IP), on bloque l’attaquant avant qu’il ne consomme des ressources qui devraient servir aux utilisateurs légitimes.

Une dimension business

Enfin, le pattern porte souvent une dimension business. Le pricing par quota en est l’exemple le plus parlant : c’est le modèle de la quasi-totalité des systèmes qui exposent des LLMs : ChatGPT, Cursor, Claude, et bien d’autres. Le plan que vous payez détermine le nombre de requêtes que vous pouvez émettre. Ici, le Rate Limiting n’est plus seulement une protection technique, il devient le mécanisme qui matérialise une offre commerciale.

Le lien avec les autres patterns de résilience

Le Rate Limiting ne vit pas en vase clos. Il dialogue directement avec deux des patterns vus précédemment, et c’est en comprenant ces liens qu’on saisit sa vraie place dans une architecture résiliente.

Avec le Retry (épisode 1)

Un Retry côté client est par nature unilatéral : l’application décide seule de réessayer, sans rien savoir de l’état réel du serveur. Le Rate Limiting renverse cette logique en introduisant une véritable collaboration entre les deux extrémités.

Dans un contexte HTTP, le serveur dispose pour cela d’un vocabulaire standardisé. Lorsqu’un client dépasse sa limite, il peut répondre :

C’est alors le serveur, et non plus le client, qui dicte la stratégie de Retry. Cette inversion est précieuse : elle permet de réguler la charge réelle du système à la source, au lieu de laisser chaque client réessayer selon sa propre logique, souvent au pire moment.

Avec le Circuit Breaker (épisode 3)

Le Rate Limiting et le Circuit Breaker se ressemblent énormément dans leur implémentation : tous deux fonctionnent comme un disjoncteur on/off qui laisse passer ou coupe le trafic. Mais leur rôle est diamétralement opposé.

PatternProtègeAgit
Circuit Breakerle client face à un service instableen réaction à des pannes côté downstream
Rate Limitingle serveur face à une surchargeen prévention d’une saturation côté upstream

Le premier réagit après coup, quand le service d’en face défaille déjà ; le second anticipe, en refusant le trafic excédentaire avant qu’il ne fasse mal. Ensemble, ils permettent à un système distribué de rester sous contrôle des deux côtés : en appelant avec prudence, et en recevant avec mesure.

Deux niveaux d’implémentation

En pratique, le Rate Limiting se met en place à deux endroits complémentaires, chacun répondant à une préoccupation distincte.

Au niveau infrastructure

Pour la dimension sécurité, celle de la première ligne de défense, mieux vaut confier le Rate Limiting à un reverse proxy comme NGINX ou HAProxy. Trois raisons à cela : la responsabilité (le filtrage du trafic abusif n’a pas à polluer votre code métier), l’isolation (la requête malveillante est rejetée avant même d’atteindre l’application) et la performance (un proxy est taillé pour ce travail et l’exécute à moindre coût).

Voici une configuration NGINX qui plafonne le trafic par adresse IP avant de le transmettre à l’application :

worker_processes auto;

events {
    worker_connections 4096;
}

http {
    # 50 requêtes par seconde par adresse IP
    limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=50r/s;

    # Code HTTP 429 (Too Many Requests) au lieu du 503 par défaut pour être explicite
    limit_req_status 429;

    # (Optionnel) Journalisation des requêtes refusées
    error_log /var/log/nginx/ratelimit.log notice;

    server {
        listen 80;

        location /api/ {
            # Applique la limite définie ci-dessus :
            # - autorise jusqu'à 50 requêtes par seconde par IP
            # - autorise un "burst" de 20 requêtes instantanées supplémentaires
            # - "nodelay" signifie que les requêtes au-delà sont rejetées immédiatement
            limit_req zone=req_limit_per_ip burst=20 nodelay;

            # Cible applicative, au sein de laquelle une logique de Rate Limiting
            # complémentaire peut être appliquée en fonction de la logique business
            proxy_pass http://localhost:3000;

            # (Optionnel) En-tête HTTP pour aider le client à savoir quand retenter
            # Attention : valeur fixe indicative, pas liée à la logique NGINX interne
            add_header Retry-After 120 always;
        }
    }
}

Au niveau applicatif

Pour la dimension métier, en revanche, l’infrastructure ne suffit plus. Gérer des quotas par utilisateur suppose de connaître qui émet la requête (son API Key, son identifiant, son plan), une information que seule l’application possède. C’est donc dans le code que cette logique trouve sa place, le plus souvent adossée à un store rapide comme Redis ou Memcached pour partager les compteurs entre instances.

L’exemple ci-dessous, avec Fastify et son plugin @fastify/rate-limit, applique un quota différencié selon le plan de l’utilisateur (free, pro ou premium), identifié par son API Key, avec un repli sur l’adresse IP pour les requêtes anonymes :

import Fastify from "fastify";

const fastify = Fastify({ logger: true });

const users = {
  "apikey-123abc": { plan: "free" },
  "apikey-236abc": { plan: "pro" },
  "apikey-23610202abc": { plan: "premium" },
} as const;

type ApiKey = keyof typeof users;

const isPaidUser = (value: string | undefined): value is ApiKey =>
  !!value && value in users;

const quotaPerPlan = {
  free: { max: 5, timeWindow: 60 * 1000 }, // 5 requests par minute
  pro: { max: 100, timeWindow: 60 * 1000 }, // 100 requests par minute
  premium: { max: 1000, timeWindow: 60 * 1000 }, // 1000 requests par minute
} as const;

function getUserQuota(key: string) {
  if (isPaidUser(key)) {
    const { plan } = users[key];
    return quotaPerPlan[plan];
  }
  return quotaPerPlan.free;
}

await fastify.register(import("@fastify/rate-limit"), {
  keyGenerator: (req) => {
    const apiKey = req.headers["x-api-key"] as string | undefined;
    // Si l'utilisateur fournit une API Key valide, on l'utilise comme
    // base identifiant pour la stratégie de Rate Limiting.
    if (isPaidUser(apiKey)) {
      return apiKey;
    }
    // Autrement, on utilise l'adresse IP de l'utilisateur.
    return req.ip;
  },
  // Nombre de requêtes autorisées par timeWindow selon le type d'utilisateur.
  max: (_request, key) => getUserQuota(key).max,
  // Durée de la fenêtre selon le type d'utilisateur.
  timeWindow: (_request, key) => getUserQuota(key).timeWindow,
  errorResponseBuilder: (_request, context) => ({
    statusCode: 429,
    error: "Too Many Requests",
    message: `Quota exceeded. Retry after ${context.after}`,
    retryAfter: context.after,
  }),
});

Le résultat vu côté client

Tant que l’utilisateur reste sous son quota, la requête passe et le serveur renvoie un 200 OK, accompagné d’en-têtes qui exposent l’état du compteur, combien de requêtes il lui reste, dans quel délai la fenêtre se réinitialise :

< HTTP/1.1 200 OK
< x-ratelimit-limit: 5
< x-ratelimit-remaining: 4
< x-ratelimit-reset: 60
< content-type: application/json; charset=utf-8
<
{"message":"OK"}

Dès qu’il franchit la limite, la réponse bascule en 429 Too Many Requests. Le header retry-after lui indique précisément quand revenir, et le corps de la réponse détaille la raison du refus :

< HTTP/1.1 429 Too Many Requests
< x-ratelimit-limit: 5
< x-ratelimit-remaining: 0
< x-ratelimit-reset: 5
< retry-after: 5
< content-type: application/json; charset=utf-8
<
{"statusCode":429,"error":"Too Many Requests","message":"Quota exceeded. Retry after 5 seconds","retryAfter":"5 seconds"}

On retrouve ici, concrètement, la collaboration évoquée plus haut : le serveur ne se contente pas de claquer la porte, il fournit au client toutes les informations nécessaires pour réessayer intelligemment.

Conclusion

La résilience ne se construit pas dans un seul sens. On passe beaucoup de temps à protéger nos appels sortants (Retry, Timeout, Circuit Breaker), mais un système n’est solide que s’il sait aussi se défendre contre ce qui entre. C’est précisément le rôle du Rate Limiting : maîtriser la charge à la source, pour des raisons de stabilité, de sécurité et parfois de modèle économique.

En le déployant aux deux niveaux (un reverse proxy en première ligne pour filtrer les abus, une logique applicative pour gérer les quotas métier), on obtient un serveur qui reste maître de son débit, quoi qu’il arrive en face. Protégez vos appels sortants, mais protégez tout autant ce qui entre : c’est à cette condition qu’un système distribué reste sous contrôle de bout en bout.