# Limitation de débit : à la bordure, dans la passerelle ou dans l'application ? Trois couches, trois rôles

« Ajouter de la limitation de débit » est un ticket d'une ligne qui cache trois problèmes différents. Quelqu'un martèle la route de connexion depuis une seule IP : c'est de l'abus, et vous voulez l'arrêter avant qu'il ne vous coûte du calcul. Un partenaire d'intégration appelle l'API trop vite pour son forfait : c'est de l'équité, et vous voulez le ralentir et lui dire pourquoi. Un utilisateur tente de passer mille commandes par minute : c'est une règle métier, et vous voulez que l'application dise non d'une façon que le produit comprend.

Chaque problème a une couche où il est bon marché et correct à résoudre, et une couche où c'est cher ou faux. Voici comment nous l'avons découpé pour une API publique sur AWS, avec les chiffres, le code, et le seul endroit où nous ne limitons délibérément pas.

## Les trois couches

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Trois couches traversées par une requête. Bordure : règle WAF basée sur le débit, par IP, fenêtre de 5 minutes, bloque avant le calcul, ne sait rien des utilisateurs. Passerelle : plans d'utilisation API Gateway par clé d'API, débits en rafale et stable, 429 avec en-têtes, connaît le client mais pas le métier. Application : seau à jetons dans DynamoDB par utilisateur ou tenant, connaît le forfait et la règle métier, renvoie 429 avec Retry-After. Chaque couche attrape ce que la précédente ne peut pas voir.">
<defs><marker id="arrL" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="90" width="90" height="60" rx="10" fill="#151b2e" stroke="#9aa3c7" stroke-width="1.5"/><text x="60" y="125" text-anchor="middle" fill="#f1f3ff">client</text>
<line x1="107" y1="120" x2="143" y2="120" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrL)"/>
<rect x="145" y="40" width="220" height="160" rx="12" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="255" y="64" text-anchor="middle" fill="#ff6b8a" font-weight="700">bordure · WAF</text><text x="255" y="88" text-anchor="middle" fill="#f1f3ff">règle sur le débit · par IP</text><text x="255" y="106" text-anchor="middle" fill="#f1f3ff">2 000 / 5 min · 100 sur /auth</text><text x="255" y="130" text-anchor="middle" fill="#9aa3c7" font-size="11">bloque avant tout calcul</text><text x="255" y="148" text-anchor="middle" fill="#9aa3c7" font-size="11">connaît les IP, pas les utilisateurs</text><text x="255" y="182" text-anchor="middle" fill="#ff6b8a" font-size="11">rôle : l'abus</text>
<line x1="367" y1="120" x2="403" y2="120" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrL)"/>
<rect x="405" y="40" width="220" height="160" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="515" y="64" text-anchor="middle" fill="#ffd166" font-weight="700">passerelle · API Gateway</text><text x="515" y="88" text-anchor="middle" fill="#f1f3ff">plan d'utilisation par clé d'API</text><text x="515" y="106" text-anchor="middle" fill="#f1f3ff">rafale 50 · stable 10 / s</text><text x="515" y="130" text-anchor="middle" fill="#9aa3c7" font-size="11">429 avec en-têtes, sans code</text><text x="515" y="148" text-anchor="middle" fill="#9aa3c7" font-size="11">connaît le client, pas le métier</text><text x="515" y="182" text-anchor="middle" fill="#ffd166" font-size="11">rôle : équité entre clients</text>
<line x1="627" y1="120" x2="663" y2="120" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrL)"/>
<rect x="665" y="40" width="220" height="160" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="775" y="64" text-anchor="middle" fill="#4fffb0" font-weight="700">application</text><text x="775" y="88" text-anchor="middle" fill="#f1f3ff">seau à jetons dans DynamoDB</text><text x="775" y="106" text-anchor="middle" fill="#f1f3ff">par utilisateur · tenant · forfait</text><text x="775" y="130" text-anchor="middle" fill="#9aa3c7" font-size="11">429 + Retry-After + un message</text><text x="775" y="148" text-anchor="middle" fill="#9aa3c7" font-size="11">connaît la règle métier</text><text x="775" y="182" text-anchor="middle" fill="#4fffb0" font-size="11">rôle : limites métier</text>
<text x="450" y="236" text-anchor="middle" fill="#9aa3c7">Chaque couche arrête ce que la précédente ne peut pas voir. Aucune ne devrait faire le travail des autres.</text>
</g>
</svg>
</div>

**La bordure** voit des IP, des chemins et des en-têtes, et rien de qui est l'utilisateur. C'est l'endroit le moins cher pour rejeter une requête, parce que ça arrive avant tout calcul que vous payez, et le plus grossier, parce que tout le monde derrière un NAT d'entreprise partage une IP. Son rôle est l'abus : credential stuffing, scrapers, un client mal configuré dans une boucle de réessai.

**La passerelle** voit une clé d'API, si vous en émettez, et peut imposer un débit par clé. Elle sait quel client appelle, pas quel utilisateur, ni ce que signifie la requête. Son rôle est l'équité entre intégrateurs : le script emballé de personne ne dégrade l'API pour tous les autres.

**L'application** voit l'utilisateur authentifié, son forfait, le tenant auquel il appartient et ce que la requête tente de faire. C'est la seule couche qui peut dire « vous avez passé 50 commandes cette heure-ci, votre forfait en autorise 50, réessayez à 15 h 00 ». Son rôle, ce sont les limites métier, et c'est la seule couche qui peut renvoyer une erreur que le produit sait expliquer.

## Couche 1 : la règle WAF basée sur le débit

Nous avons couvert [la configuration WAF](/fr/blog/waf-for-a-nextjs-app-managed-rules-that-block-legit-traffic) séparément ; la règle de débit est la partie qui a arrêté de vraies attaques. Deux règles, parce qu'une seule limite pour tout le site est fausse :

```ts
// 2 000 requêtes par 5 minutes depuis une IP, sur tout
{ name: 'rate-all', priority: 40,
  statement: { rateBasedStatement: { limit: 2000, evaluationWindowSec: 300, aggregateKeyType: 'IP' } },
  action: { block: { customResponse: { responseCode: 429 } } } },

// 100 par 5 minutes sur les routes d'authentification, où un « utilisateur » en fait peut-être 5
{ name: 'rate-auth', priority: 41,
  statement: { rateBasedStatement: { limit: 100, evaluationWindowSec: 300, aggregateKeyType: 'IP',
    scopeDownStatement: { byteMatchStatement: { fieldToMatch: { uriPath: {} }, positionalConstraint: 'STARTS_WITH', searchString: '/api/auth/', textTransformations: [{ priority: 0, type: 'LOWERCASE' }] } } } },
  action: { block: { customResponse: { responseCode: 429 } } } },
```

Deux détails. La réponse personnalisée fait du blocage un 429 plutôt que le 403 par défaut du WAF, pour que les clients qui comprennent la limitation de débit se comportent correctement. Et la fenêtre est de cinq minutes avec une limite de 100 sur l'auth, qu'un vrai utilisateur n'approche jamais et qu'un script de credential stuffing atteint dans les dix premières secondes. En six mois, cette règle a bloqué trois tentatives de ce type et un scraper. Elle coûte 1 $ par mois.

Ce qu'elle ne peut pas faire : distinguer cent utilisateurs derrière une IP de bureau d'un seul attaquant. Quand toute l'entreprise d'un client s'est retrouvée bloquée à la connexion parce qu'ils étaient tous arrivés à 9 h 00, le correctif n'a pas été d'augmenter la limite ; ç'a été d'ajouter un scope-down qui exclut leur plage de sortie connue, ce qui est un changement de deux lignes et une conversation avec leur équipe informatique.

## Couche 2 : les plans d'utilisation API Gateway, et pourquoi nous ne les utilisons pas

Si votre API est derrière API Gateway et que vous émettez des clés pour les intégrateurs, les plans d'utilisation sont le bon outil : un débit en rafale et un débit stable par clé, imposés par la passerelle, renvoyant 429 sans écrire de code. C'est ce que nous utiliserions pour un produit d'API public avec des intégrateurs payants par palier.

Nous n'avons pas cette forme. Notre API est appelée par nos propres front-ends et par une poignée de partenaires, et elle tourne sur App Runner, pas derrière API Gateway. Ajouter une passerelle juste pour la limitation de débit ajouterait un saut, un coût (3,50 $ par million de requêtes, plus que le WAF) et un délai de 29 secondes. Donc le rôle « équité entre clients » est passé dans la couche application, indexé par l'identité du partenaire plutôt que par une clé d'API. Si nous avions cinquante intégrateurs au lieu de cinq, l'arithmétique basculerait et nous mettrions la passerelle.

## Couche 3 : l'application

Le limiteur applicatif est indexé par ce sur quoi porte la règle métier : l'utilisateur, le tenant, le partenaire, parfois la ressource. C'est un seau à jetons dans DynamoDB, parce que c'est un magasin que nous avons déjà, qu'il est atomique et qu'il est bon marché à notre débit de requêtes. Un élément par clé, rempli à la lecture :

```ts
// lib/rate-limit.ts
export async function take(key: string, plan: { capacity: number; refillPerSec: number }): Promise<Allow | Deny> {
  const now = Date.now() / 1000;
  const res = await ddb.update({
    TableName: 'rate-limits', Key: { pk: key },
    // remplir jusqu'à la capacité selon le temps écoulé, puis prendre un jeton
    UpdateExpression: 'SET tokens = :cap - :one, updatedAt = :now',
    ConditionExpression: 'attribute_not_exists(pk) OR (tokens + (:now - updatedAt) * :refill) >= :one',
    ExpressionAttributeValues: { ':cap': plan.capacity, ':one': 1, ':now': now, ':refill': plan.refillPerSec },
    ReturnValues: 'ALL_NEW',
  }).catch(e => e.name === 'ConditionalCheckFailedException' ? null : Promise.reject(e));
  if (!res) return { allowed: false, retryAfterSec: Math.ceil(1 / plan.refillPerSec) };
  return { allowed: true, remaining: res.Attributes.tokens };
}
```

La vraie version fait quelques lignes de plus, parce que les expressions de mise à jour de DynamoDB ne peuvent pas faire toute l'arithmétique de remplissage en une seule instruction sans `min()`, donc c'est un lire-calculer-écrire-conditionnel avec réessai en cas de conflit. L'important, c'est la forme : une opération atomique par requête, pas de Redis à exploiter, les éléments expirent avec un TTL donc les clés inactives ne coûtent rien.

La réponse en cas de refus est ce qui justifie le coût de cette couche :

```
HTTP/1.1 429 Too Many Requests
Retry-After: 6
RateLimit-Limit: 50
RateLimit-Remaining: 0
RateLimit-Reset: 6
Content-Type: application/json

{ "error": "rate_limited", "message": "Votre forfait autorise 50 commandes par heure. Réessayez dans 6 secondes, ou passez au forfait supérieur pour lever cette limite.", "upgradeUrl": "/billing" }
```

Un 429 que le produit peut afficher. Le WAF ne peut pas écrire ce message, parce qu'il ne sait pas ce qu'est une commande.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Tableau de quelle couche gère quel cas. Credential stuffing depuis une IP : WAF. Scraper aspirant chaque page : WAF. Script partenaire dans une boucle de réessai : application, indexé par partenaire. Utilisateur passant trop de commandes pour son forfait : application, indexé par utilisateur. Tout un bureau derrière un NAT bloqué à la connexion : scope-down WAF pour leur plage de sortie, pas une limite plus haute. Health checks et webhooks : exemptés à chaque couche.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Qui gère quoi</text>
<g fill="#9aa3c7"><text x="20" y="54" font-weight="700" fill="#f1f3ff">cas</text><text x="470" y="54" font-weight="700" fill="#f1f3ff">couche</text><text x="620" y="54" font-weight="700" fill="#f1f3ff">clé</text></g>
<line x1="20" y1="62" x2="880" y2="62" stroke="#2a3150"/>
<text x="20" y="86" fill="#f1f3ff">credential stuffing sur /api/auth</text><text x="470" y="86" fill="#ff6b8a" font-weight="700">WAF</text><text x="620" y="86" fill="#9aa3c7">IP · 100 / 5 min</text>
<text x="20" y="110" fill="#f1f3ff">scraper aspirant chaque page produit</text><text x="470" y="110" fill="#ff6b8a" font-weight="700">WAF</text><text x="620" y="110" fill="#9aa3c7">IP · 2 000 / 5 min</text>
<text x="20" y="134" fill="#f1f3ff">script partenaire coincé dans une boucle de réessai</text><text x="470" y="134" fill="#4fffb0" font-weight="700">application</text><text x="620" y="134" fill="#9aa3c7">id partenaire · son forfait</text>
<text x="20" y="158" fill="#f1f3ff">utilisateur passant plus de commandes que son forfait</text><text x="470" y="158" fill="#4fffb0" font-weight="700">application</text><text x="620" y="158" fill="#9aa3c7">id utilisateur · 50 / heure</text>
<text x="20" y="182" fill="#f1f3ff">tout un bureau derrière un NAT bloqué à 9 h 00</text><text x="470" y="182" fill="#ffd166" font-weight="700">scope-down WAF</text><text x="620" y="182" fill="#9aa3c7">leur plage de sortie, pas une limite plus haute</text>
<text x="20" y="206" fill="#f1f3ff">health checks · webhooks signés</text><text x="470" y="206" fill="#7b8cff" font-weight="700">exemptés partout</text><text x="620" y="206" fill="#9aa3c7">limité par chemin, à chaque couche</text>
</g>
</svg>
</div>

## Là où nous ne limitons délibérément pas

Les récepteurs de webhooks. Un prestataire de paiement qui réessaie une livraison n'est pas de l'abus, c'est le protocole qui fonctionne, et une rafale de cent webhooks après leur panne est exactement le moment où vous avez le plus besoin de tous les accepter. Les routes de webhook sont authentifiées par signature, elles sont exemptées de la règle de débit WAF par chemin, et l'application ne les met pas dans un seau. Si elles posent un problème de charge, le correctif est une file derrière le récepteur, pas une limite devant.

Les health checks, pour la même raison, et parce qu'[un health check limité en débit est un health check qui ment](/fr/blog/health-checks-that-lie).

## Ce que ça coûte, et ce que ça a attrapé

| Couche | Coût mensuel | Ce qu'elle a arrêté en six mois |
|---|---|---|
| Règles de débit WAF (2) | ~2 $ | 3 campagnes de credential stuffing, 1 scraper, 1 robot de supervision mal configuré |
| Plans d'utilisation API Gateway | 0 $ (non utilisés) | n/a |
| Limiteur applicatif (DynamoDB) | ~1 $ en écritures à la demande | 2 boucles de réessai de partenaires, ~40 utilisateurs par jour atteignant les limites de forfait, c'est-à-dire le produit qui fonctionne |

Cinq dollars par mois, un après-midi pour les règles WAF, une journée pour le limiteur applicatif et son corps de 429. La ligne la plus précieuse est la dernière : quarante utilisateurs par jour qui voient un message disant quelle est la limite et comment la lever, au lieu d'une erreur générique, parce que la limite vit dans la couche qui sait ce qu'elle signifie.

Si vous avez une seule limite de débit pour tout et qu'elle est soit trop lâche pour arrêter l'abus, soit trop stricte pour les vrais utilisateurs, c'est le signe qu'elle est dans la mauvaise couche. [Nous vous aiderons à la découper](/contact).
