Construction d’un limiteur de débit bucket token sur DynamoDB

Temps de lecture : 9 min
Points clés à retenir
- Écritures conditionnelles atomiques : Utilisez des expressions ConditionExpression pour vérifier et décrémenter le nombre de tokens en une seule opération sur DynamoDB, garantissant ainsi l’intégrité même sous forte charge.
- Verrouillage optimiste : Gérez la concurrence avec un compteur de version (ConditionExpression sur l’attribut version) et une boucle de retry en cas d’échec conditionnel.
- TTL intelligente : Activez Time-to-Live sur votre table DynamoDB pour supprimer automatiquement les buckets inactifs après leur expiration, réduisant les coûts.
Contexte et motivation
Lorsque vous gérez une API publique, un limiteur de débit est indispensable pour éviter les abus, garantir une utilisation équitable et protéger vos services. J’ai dû en implémenter un pour GymLog, mon app fitness, quand les pics de requêtes provenant de certains clients mettaient à genoux l’infrastructure.
L’approche classique avec Redis est la plus courante, mais dans mon stack cloud (AWS + DynamoDB), j’ai voulu tout centraliser sur DynamoDB. Concrètement, pourquoi ajouter une couche Redis si vous pouvez faire la même chose avec votre base NoSQL préférée ?
Principe du token bucket
Imaginez un seau rempli de jetons. Chaque jeton autorise une requête. Le seau se remplit à un rythme constant (ex : 1 jeton par seconde) et a une capacité maximale (ex : 10 jetons). Quand une requête arrive :
- Si un jeton est disponible, on le consomme et la requête passe.
- Sinon, la requête est rejetée (HTTP 429 Too Many Requests).
C’est simple, efficace, et cela lisse les pics de trafic. Pour une implémentation dispersée (plusieurs instances d’API), le stockage partagé doit être atomique, d’où le choix de DynamoDB.
Modèle de données sur DynamoDB
La première décision : la clé primaire. Un bucket unique par utilisateur ou par IP. J’opte pour une clé de partition composite : rate_limiter#{id_client}. Ensuite, les attributs :
tokens: le nombre de jetons actuellement disponibles (entier).last_refill_time: timestamp de la dernière recharge.capacity: capacité maximale (configuration).refill_rate: nombre de jetons ajoutés par seconde.version: compteur pour le verrouillage optimiste.
Écriture conditionnelle atomique
L’élément clé de la solution. DynamoDB permet d’ajouter une ConditionExpression à une opération d’écriture. Concrètement, pour consommer un jeton :
UpdateItemRequest request = new UpdateItemRequest();
request.setTableName("RateLimiter");
request.setKey(Map.of("pk", new AttributeValue("rate_limiter#" + clientId)));
request.setUpdateExpression("SET tokens = tokens - :dec, last_refill_time = :now");
request.setConditionExpression("tokens >= :dec");
request.setExpressionAttributeValues(Map.of(
":dec", new AttributeValue().withN("1"),
":now", new AttributeValue().withN(String.valueOf(now))
));
try {
dynamoDB.updateItem(request);
// Requête acceptée
} catch (ConditionalCheckFailedException e) {
// Pas assez de jetons : rejeter la requête
}
L’écriture échoue si le nombre de jetons est inférieur à 1, et ce de manière atomique : pas de risque de dépassement en cas de concurrence. J’utilise ce pattern dans un workflow n8n pour un projet client, et la robustesse est au rendez-vous.
Gestion du refill (remplissage)
Si l’on rechargeait le bucket à chaque appel, on risquerait de perdre les jetons non utilisés. L’astuce : calculer le nombre de jetons à ajouter en fonction du temps écoulé depuis last_refill_time.
Plus précisément, avant chaque mise à jour conditionnelle :
- Lire l’item (ou utiliser la version optimiste).
- Calculer les secondes écoulées :
now - last_refill_time. - Ajouter le nombre adéquat :
tokens = min(capacity, tokens + elapsed * refill_rate). - Exécuter l’UpdateItem avec la ConditionExpression sur tokens >= 1.
Pour éviter une lecture séparée, on peut utiliser une opération atomic combinée avec une ExpressionAttributeValues qui stocke le timestamp actuel, et une ConditionExpression qui vérifie à la fois le nombre et le temps écoulé. Mais la simplicité de l’approche en deux étapes est largement suffisante pour 99% des cas.
Verrouillage optimiste
Chaque item DynamoDB possède un attribut version. Lors de la mise à jour, on écrit :
request.setConditionExpression("version = :current_version");
request.setExpressionAttributeValues(Map.of(
":current_version", new AttributeValue().withN(String.valueOf(currentVersion)),
":new_version", new AttributeValue().withN(String.valueOf(currentVersion + 1))
));
request.setUpdateExpression("SET tokens = tokens - :dec, #ver = :new_version");
Si deux instances concurrentes essayent la même opération, l’une échoue avec ConditionalCheckFailedException. Dans ce cas, il faut relire l’item (pour récupérer la nouvelle version et l’état mis à jour) et réessayer. Une boucle de retry avec un nombre limité de tentatives (ex : 3) est la bonne pratique.
Nettoyage des buckets inactifs avec TTL
Les buckets inutilisés (par exemple, pour un client qui n’a pas envoyé de requête depuis 24h) consomment inutilement de la capacité. DynamoDB offre la fonctionnalité TTL (Time-to-Live).
- Ajoutez un attribut
ttl(timestamp Unix). - Lors de chaque mise à jour, définissez
ttl = now + 3600(1 heure). - Si aucun appel n’arrive dans l’heure, DynamoDB supprime l’item. Ce nettoyage prend quelques heures, mais il est automatique.
Concrètement, pour un bucket qui reste actif, le TTL est repoussé perpétuellement. Seuls les buckets réellement inactifs disparaissent.
Gestion des erreurs et des limites
L’implémentation présentée fonctionne parfaitement pour des charges modérées. Mais il y a des points à surveiller :
- Provisioned Throughput : DynamoDB limite les opérations en lecture/écriture par seconde. Sous un pic massif, vous pouvez atteindre la limite. Solution : utiliser du mode auto-scaling ou prévoir des retries avec backoff exponentiel.
- Latence : Chaque opération ajoute ~10-30 ms. Pour une API nécessitant une latence inférieure à 1 ms, Redis reste plus performant.
- Cas de contention fort : Si un seul client envoie des milliers de requêtes par seconde sur un même bucket, le verrouillage optimiste et les ConditionExpression génèrent beaucoup d’échecs. Dans ce cas, il faut sharder par bucket (plusieurs items par client, ou passer sur un algorithme de sliding window).
Mise en production : ajout de métriques
En production, j’ajoute toujours des métriques CloudWatch :
- Nombre de requêtes acceptées vs rejetées.
- Nombre de ConditionalCheckFailed par minute.
- Taille totale de la table RateLimiter.
Cela m’a permis de détecter un bug dans mon calcul de refill pour GymLog : en période de forte charge, le timestamp last_refill_time était parfois écrasé avec une valeur trop récente, ce qui provoquait un sous-remplissage des buckets. Un simple fix de logique a résolu le problème.
Conclusion
Implémenter un token bucket rate limiter sur DynamoDB est tout à fait réalisable et suffisant pour des applications de taille moyenne, sans infrastructure supplémentaire. Les écritures conditionnelles atomiques et le verrouillage optimiste garantissent la cohérence sous concurrence. Le TTL simplifie le nettoyage.
Pour un projet qui demande une latence très faible ou des centaines de milliers de requêtes par seconde, Redis restera la meilleure option. Mais DynamoDB offre une solution robuste et 100% managée, sans serveur supplémentaire. En juillet 2026, alors que les architectures serverless gagnent du terrain, ce type d’approche en vaut vraiment la peine.

Développeur full-stack depuis 25 ans, je suis passé du PHP des années 2000 aux stacks modernes (Next.js, React Native, IA). J’accompagne entrepreneurs et créateurs dans leurs projets digitaux avec une approche pragmatique : du code aux résultats concrets.