« 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
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 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 :
// 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 :
// 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.
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.
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.