Rate Limiting & Throttling
ProtĂ©ger une API contre les abus, les DDoS et la surconsommation de ressources en limitant le nombre de requĂȘtes par client sur une fenĂȘtre de temps donnĂ©e.
Pourquoi limiter ?
| Risque | Impact sans limite |
|---|---|
| Surcharge du backend | Temps de réponse qui explose, crash en cascade |
| Abus dâAPI (scraping, crawler) | CoĂ»ts infrastructure, bande passante gaspillĂ©e |
| DDoS | Indisponibilité du service |
| Iniquité entre clients | Un client monopolise les ressources |
| Surcharge de la base de données | Connexions MySQL/PostgreSQL épuisées |
Le rate limiting nâest pas de la sĂ©curitĂ© pure â câest de la rĂ©silience opĂ©rationnelle. MĂȘme une API bien conçue et bien authentifiĂ©e peut tuer le service si un seul client envoie 10 000 requĂȘtes par seconde.
StratĂ©gies dâalgorithme
Fixed Window
Coupe le temps en fenĂȘtres fixes (ex. 1 minute). Compteur reset Ă chaque changement de fenĂȘtre.
fenĂȘtre 0: |â â â â â â â â â â | â 10 req, OK
fenĂȘtre 1: |â â â â â â â â â â | â 10 req, OK- Avantage : simple Ă implĂ©menter (un compteur + timestamp de dĂ©but de fenĂȘtre).
- InconvĂ©nient : pic aux limites de fenĂȘtre. Un client peut envoyer 10 requĂȘtes en 0,5 s juste avant la coupure, puis 10 autres juste aprĂšs.
Sliding Window Log
Conserve lâhistorique de chaque requĂȘte (timestamp). Compte les requĂȘtes dans la fenĂȘtre glissante.
- Avantage : lissage parfait entre les fenĂȘtres.
- Inconvénient : coût mémoire (il faut stocker chaque timestamp).
Sliding Window Counter (approximatif)
Combine deux fenĂȘtres fixes (ancienne + nouvelle) pondĂ©rĂ©es par la proportion du temps Ă©coulĂ© dans la fenĂȘtre courante. Environnement Redis typique pour le gateway.
Token Bucket
Un « seau » se remplit Ă taux constant (replenishRate). Chaque requĂȘte consomme un jeton. Si le seau est vide, la requĂȘte est rejetĂ©e.
bucket: 20 jetons max, 1 jeton/sec â 20 req immĂ©diates puis 1 req/sec- Avantage : lisse les bursts (le seau se remplit lentement).
- Inconvénient : nécessite un systÚme distribué pour fonctionner à plusieurs instances.
Leaky Bucket
File dâattente avec dĂ©bit de sortie constant. Les requĂȘtes en excĂšs sont rejetĂ©es.
- Avantage : débit de sortie toujours constant (utile pour protéger des backends fragiles).
- InconvĂ©nient : les requĂȘtes en excĂšs sont perdues (pas de mise en file dâattente).
ImplĂ©mentation Spring Boot â Resilience4j
Dépendance
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-ratelimiter</artifactId>
</dependency>Annotation @RateLimiter sur un service
@RateLimiter(name = "apiRateLimiter", fallbackMethod = "fallback")
public Mono<ResponseEntity<ProduitDTO>> obtenirProduit(UUID id) {
ProduitDTO produit = service.obtenir(id);
return Mono.just(ResponseEntity.ok(produit));
}
// Méthode appelée quand le rate limit est atteint
public Mono<ResponseEntity<ProduitDTO>> fallback(
UUID id, RateLimiterNotPermittedException ex) {
return Mono.just(ResponseEntity
.status(HttpStatus.TOO_MANY_REQUESTS)
.body(null));
}Configuration YAML
resilience4j:
ratelimiter:
instances:
apiRateLimiter:
limitForPeriod: 100 # requĂȘtes max par fenĂȘtre
limitRefreshPeriod: 60s # durĂ©e de la fenĂȘtre
timeoutDuration: 0s # attente avant rejet (0 = rejet immédiat)
registerHealthIndicator: trueEn-tĂȘtes ajoutĂ©s automatiquement (Spring Boot 3.2+)
Depuis Spring Boot 3.2, lâannotation @RateLimiter ajoute automatiquement les en-tĂȘtes X-RateLimit-Limit, X-RateLimit-Remaining et X-RateLimit-Reset Ă la rĂ©ponse.
Note : les en-tĂȘtes sont ajoutĂ©s automatiquement depuis Spring Boot 3.2 avec Resilience4j 2.x. Pour les versions antĂ©rieures, il faut configurer un filtre personnalisĂ© pour les ajouter.
Implémentation Spring Cloud Gateway
Le gateway est le lieu idĂ©al pour le rate limiting : il voit toutes les requĂȘtes, avant quâelles nâatteignent les services.
Architecture Redis-backed
Client â Gateway â [RequestRateLimiter] â Service
â
Redis (compteur partagĂ©)Redis permet un compteur partagĂ© entre plusieurs instances du gateway â indispensable en production avec load balancing.
Configuration YAML
spring:
cloud:
gateway:
routes:
- id: service-public
uri: lb://service-public
predicates:
- Path=/api/public/**
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@ipKeyResolver}"
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
redis-rate-limiter.requestedTokens: 1Clés de résolution
// KeyResolver par IP (prĂȘt Ă l'emploi)
@Bean
public KeyResolver ipKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest().getRemoteAddress()
.getAddress().getHostAddress()
);
}
// KeyResolver par identifiant utilisateur (aprĂšs authentification JWT)
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.just(
exchange.getAttribute("authenticatedPrincipal")
.orElse("anonymous")
);
}
// KeyResolver combiné : utilisateur si authentifié, sinon IP
@Bean
public KeyResolver compositeKeyResolver() {
return exchange -> {
String principal = exchange.getAttribute("authenticatedPrincipal");
if (principal != null) {
return Mono.just(principal);
}
return Mono.just(
exchange.getRequest().getRemoteAddress()
.getAddress().getHostAddress()
);
};
}PiĂšge :
RequestRateLimiterutilise Sliding Window Counter via Redis.replenishRatedĂ©finit le dĂ©bit continu,burstCapacityle nombre maximal de requĂȘtes en rafale.requestedTokens: 1= une requĂȘte = un jeton.
Filtrage conditionnel
Appliquer le rate limiting uniquement à certains chemins ou méthodes :
spring:
cloud:
gateway:
routes:
- id: service-public
uri: lb://service-public
predicates:
- Path=/api/public/**
- Method=GET,POST
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@userKeyResolver}"
redis-rate-limiter.replenishRate: 5
redis-rate-limiter.burstCapacity: 10
redis-rate-limiter.requestedTokens: 1Implémentation Micronaut
Dépendance
<dependency>
<groupId>io.micronaut.micrometer</groupId>
<artifactId>micronaut-micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>io.micronaut.micrometer</groupId>
<artifactId>micronaut-micrometer-registry-prometheus</artifactId>
</dependency>Annotation @RateLimiter (Micronaut 4+)
@Singleton
public class ProduitService {
@RateLimiter(name = "produitRateLimiter")
public ProduitDTO obtenirProduit(UUID id) {
return repo.obtenir(id)
.orElseThrow(() -> new NotFoundException("Produit introuvable"));
}
@ExceptionHandler(RateLimiterExhaustedException.class)
@Status(HttpStatus.TOO_MANY_REQUESTS)
public HttpResponse<String> onRateLimit() {
return HttpResponse.status(HttpStatus.TOO_MANY_REQUESTS)
.body("Trop de requĂȘtes â rĂ©essayez plus tard");
}
}Configuration YAML
micronaut:
time:
rate-limiter:
enabled: true
default:
maximum-permits: 100
permit-refresh-period: 60s
permit-refresh-period-time-unit: SECONDSEn-tĂȘtes de rĂ©ponse standard
RFC 7231 et pratiques courantes. Les clients (react-app, mobile) les lisent pour afficher un spinner ou un message.
| En-tĂȘte | Signification | Exemple |
|---|---|---|
X-RateLimit-Limit | Quota maximum sur la fenĂȘtre | X-RateLimit-Limit: 100 |
X-RateLimit-Remaining | RequĂȘtes restantes dans la fenĂȘtre | X-RateLimit-Remaining: 42 |
X-RateLimit-Reset | Epoch timestamp (Unix) du reset | X-RateLimit-Reset: 1720000000 |
Retry-After | DĂ©lai en secondes avant de renvoyer une requĂȘte (uniquement sur 429) | Retry-After: 30 |
Exemple de réponse 429
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1720000030
Retry-After: 30
Content-Type: application/json
{"error": "Too many requests", "retryAfterSeconds": 30}RĂšgle : toujours renvoyer un code
429 Too Many Requests(et jamais un403ou200cachĂ©) quand le quota est dĂ©passĂ©. Les clients doivent ĂȘtre capables de le dĂ©tecter.
PiĂšges
1. Spoofing dâIP avec X-Forwarded-For
Si le gateway se trouve derriĂšre un proxy (NGINX, AWS ALB), getRemoteAddress() retourne lâadresse du proxy, pas du client.
// â MAUVAIS â facile Ă spoof
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
// â
BON â lire le header de confiance (uniquement si le proxy est de confiance)
String forwarded = exchange.getRequest().getHeaders()
.getFirst("X-Forwarded-For");
String ip = (forwarded != null) ? forwarded.split(",")[0].trim() :
exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();PiĂšge : un attaquant peut ajouter lâen-tĂȘte
X-Forwarded-Fordans sa requĂȘte pour tromper le rate limiter. Seules les adresses IP de confiance (le proxy) doivent ĂȘtre autorisĂ©es Ă lâenvoyer.
2. Rate limiting bypassé par un header tronqué
Certains frameworks ignorent les en-tĂȘtes dont la longueur est trop grande. Pour le X-RateLimit-Remaining, un client qui affiche un compteur ne le voit jamais diminuer â mais le serveur continue Ă compter. Le client doit refaire la requĂȘte quand il atteint zĂ©ro.
3. Test du rate limit inadapté
Tester avec un seul thread cache la consommation réelle. Utiliser un outil de load test (k6, JMeter, Artillery) pour simuler plusieurs clients simultanément.
# Exemple k6 : 100 requĂȘtes/s pendant 30 secondes
cat <<'EOF' > rate-limit-test.js
import http from 'k6/http';
export const options = {
vus: 100,
duration: '30s',
};
export default function () {
http.get('http://localhost:8080/api/public/produits');
}
EOF
k6 run rate-limit-test.js4. Failover Redis â pas de rate limiting
Si Redis devient injoignable, RequestRateLimiter ne bloque pas â il laisse passer toutes les requĂȘtes. Toujours avoir un mĂ©canisme de repli : circuit breaker vers un fallback in-memory ou bloquer les requĂȘtes.
spring:
cloud:
gateway:
routes:
- id: service-public
uri: lb://service-public
predicates:
- Path=/api/public/**
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@userKeyResolver}"
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
redis-rate-limiter.requestedTokens: 1
# Circuit breaker en fallback si Redis est mort
- name: CircuitBreaker
args:
name: rateLimiterCircuit
fallbackUri: forward:/fallback/rate-limit5. FenĂȘtre de temps mal calibrĂ©e
- FenĂȘtre trop courte (1 s) : le client lĂ©gitime est pĂ©nalisĂ© pour des tests rapides.
- FenĂȘtre trop longue (1 heure) : un attaquant a une fenĂȘtre de tir Ă©norme.
- Recommandation : 60 s pour la plupart des APIs, ajuster selon le type dâendpoint (lecture vs Ă©criture).
Checklist de déploiement
- StratĂ©gie dâalgorithme choisie (token bucket recommandĂ© pour la plupart des cas)
- SystÚme distribué de comptage (Redis, Redis Cluster, ou serveur de consensus)
- Clé de résolution adaptée (utilisateur authentifié si possible, IP sinon)
- En-tĂȘtes
X-RateLimit-*ajoutés à chaque réponse - Code
429renvoyĂ© correctement avecRetry-After - Rejet non silencieux : le client reçoit bien un message dâerreur
- Test de charge réalisé (k6, JMeter, Artillery)
- Fallback défini si le systÚme de comptage devient injoignable
- Monitoring des indicateurs (quota utilisé, taux de 429, pics)