Rate Limiting & Throttling
Protéger une API contre la surconsommation : stratégies, implémentation Spring Boot / Micronaut / Gateway, headers, et piÚges.
Pourquoi limiter ?
| Motiv | Impact sans rate limit |
|---|---|
| Protection DDoS | Ăpuisement des threads, arrĂȘt du service |
| Fair use | Un client monopolise la bande passante |
| Protection backend | Les bases de données et caches sont fragiles sous charge |
| ContrĂŽle des coĂ»ts | Les appels API externes (paiement Ă lâusage) filent en flĂšche |
| Qualité de service | Latence augmentée pour tous les autres consommateurs |
RĂšgle : un service sans rate limiting est un service qui ne tient pas la premiĂšre tempĂȘte. Activer le throttling dĂšs le premier dĂ©ploiement en production.
Stratégies de comptage
| Stratégie | Principe | Comportement |
|---|---|---|
| Fixed window | Compteur par intervalle fixe (ex. 60 s) | CrĂȘtes Ă la bordure de lâintervalle |
| Sliding window | FenĂȘtre glissante calquĂ©e sur le temps rĂ©el | RĂ©partition uniforme, plus lisse |
| Token bucket | Stock de N tokens, un par requĂȘte, rĂ©gĂ©nĂ©ration lente | Autorise les bursts courts puis rĂ©gule |
| Leaky bucket | File dâattente, service Ă dĂ©bit constant | Lissage parfait mais latence accrue |
Choix rapide :
sliding-windowpour le fair-use classique,token-bucketquand les bursts clients sont tolĂ©rĂ©s (ex. pagination, requĂȘtes rapides successives).
Headers de réponse standards
Conventions de fait pour informer le client de sa fenĂȘtre (le header Retry-After est dĂ©fini par RFC 7231) :
| Header | Signification |
|---|---|
X-RateLimit-Limit | Nombre maximum dâappels dans la fenĂȘtre |
X-RateLimit-Remaining | Appels restants avant blocage |
X-RateLimit-Reset | Heure (Unix epoch) de réinitialisation du compteur |
Retry-After | Secondes avant de réessayer (couplé au 429) |
Exemple de réponse 429 :
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1720000060
Retry-After: 30
Content-Type: application/json
{"error": {"code": "RATE_LIMITED", "message": "Trop de requĂȘtes. RĂ©essayez dans 30 secondes."}}ImplĂ©mentation â Spring Boot (cĂŽtĂ© application)
Dépendance Resilience4j
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-ratelimiter</artifactId>
<version>2.2.3</version>
</dependency>Configuration application.yml
resilience4j:
ratelimiter:
instances:
api-limiter:
limitForPeriod: 100
limitRefreshPeriod: 60s
timeoutDuration: 0s
registerHealthIndicator: trueAnnotation @RateLimiter
@RestController
@RequestMapping("/api/utilisateurs")
public class UtilisateurController {
@GetMapping
@RateLimiter(name = "api-limiter", fallbackMethod = "limiteAtteinte")
public ResponseEntity<List<UtilisateurDTO>> lister() {
return ResponseEntity.ok(utilisateurService.lister());
}
public ResponseEntity<String> limiteAtteinte() {
return ResponseEntity.status(429)
.header("Retry-After", "30")
.body("{\"error\": {\"code\": \"RATE_LIMITED\"}}");
}
}PiĂšge :
@RateLimiterde Resilience4j utilise unSemaphorelocal Ă lâinstance JVM. Dans un dĂ©ploiement multi-instance, chaque serveur a son propre compteur â le vrai quota de production est atteignable sans blocage. Pour un quota global, utiliser la version Redis de Resilience4j ou un rate limiter cĂŽtĂ© gateway.
Rate limiter distribué Redis (Resilience4j)
resilience4j:
ratelimiter:
instances:
api-limiter:
limitForPeriod: 100
limitRefreshPeriod: 60s
waitDuration: 0s
registry-customizer:
eventConsumerBufferSize: 1024@Configuration
public class RateLimiterConfig {
@Bean
public RedisRateLimiter redisRateLimiter(RedisConnectionFactory factory) {
return new RedisRateLimiter(factory);
}
}ImplĂ©mentation â Spring Cloud Gateway
La configuration gateway est détaillée dans la page Spring Cloud Gateway. Cette section rappelle les éléments essentiels spécifiques au rate limiting et ajoute des patterns complémentaires.
Filtre RequestRateLimiter
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: 1Résolveur de clé par utilisateur authentifié
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(
exchange.getRequest().getHeaders().getFirst("Authorization")
).map(auth -> auth.replace("Bearer ", ""));
}PiĂšge : si le client nâa pas dâauthentification,
userKeyResolverrenvoieMono.empty(). La requĂȘte est alors Ă©valuĂ©e sans quota et passe sans ĂȘtre limitĂ©e. Tester avec un client anonyme avant de dĂ©ployer.
CritĂšres de match granulaires
Associer un quota différent selon le type de route :
spring:
cloud:
gateway:
routes:
- id: api-public
uri: lb://service-public
predicates:
- Path=/api/public/**
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@ipKeyResolver}"
redis-rate-limiter.replenishRate: 5
redis-rate-limiter.burstCapacity: 10
- id: api-prive
uri: lb://service-prive
predicates:
- Path=/api/membre/**
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@userKeyResolver}"
redis-rate-limiter.replenishRate: 50
redis-rate-limiter.burstCapacity: 100ImplĂ©mentation â Micronaut
Dépendance et configuration
<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>micronaut:
metrics:
enabled: true
export:
prometheus:
enabled: true
management:
ratelimiter:
default-policy:
limit-for-period: 100
limit-refresh-period: 60s
timeout-duration: 0s
policies:
public-api:
limit-for-period: 20
limit-refresh-period: 60s
timeout-duration: 0s
private-api:
limit-for-period: 200
limit-refresh-period: 60s
timeout-duration: 0sAnnotation @RateLimiter
@Controller("/api/utilisateurs")
public class UtilisateurController {
@Get(uri = "/", swallows429 = true)
@RateLimiter(name = "public-api")
public List<UtilisateurDTO> lister() {
return utilisateurService.lister();
}
}Note :
swallows429 = truefait que Micronaut gĂ©nĂšre automatiquement la rĂ©ponse 429. Sans ce flag, lâexceptionRateLimiterExhaustedExceptionremonte au gestionnaire dâerreurs global.
Configuration Redis (stock distribué)
micronaut:
metrics:
export:
prometheus:
enabled: true
micrometer:
tags:
application: ${spring.application.name}
management:
ratelimiter:
redis:
enabled: true
key-prefix: "ratelimit:"
ttl: 60PiĂšges courants
1. IP spoofing via X-Forwarded-For
Si le rate limiter est couplĂ© Ă lâIP du client, un attaquant peut falsifier le header X-Forwarded-For. Dans un dĂ©ploiement proxyĂ© (AWS ALB, nginx, Cloudflare) :
// â MAUVAIS â utilise directement l'IP distante (peut ĂȘtre spoofĂ©e)
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
// â
BON â utiliser le dernier IP de confiance via le proxy
String forwarded = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
String clientIp = forwarded != null ? forwarded.split(",")[0].trim() : remoteAddr;PiĂšge : ne jamais faire confiance Ă
X-Forwarded-Fordans un contexte public. Valider la chaĂźne de proxy de confiance. Le proxy AWS ALB ou nginx doit réécrire lâIP distante avec la valeur deX-Forwarded-Foruniquement si le proxy est dans la liste blanche.
2. Tests du rate limit
Pour vérifier le bon fonctionnement sans attendre un intervalle entier :
# Test rapide : envoi groupĂ© de requĂȘtes, vĂ©rification du 429
for i in $(seq 1 150); do
status=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/public/test)
if [ "$status" = "429" ]; then
echo "Rate limit atteint Ă la requĂȘte $i"
break
fi
done
# Vérification des headers
curl -I http://localhost:8080/api/public/test | grep -E "X-RateLimit|Retry-After"3. Quota par requĂȘte vs quota par seconde
Parfois, limiter le nombre de requĂȘtes par seconde nâest pas suffisant. Exemples :
- API de paiement : limiter le nombre de transactions par minute
- API de recherche : limiter le coĂ»t en unitĂ©s (ex. 1 requĂȘte = 5 unitĂ©s, quota = 1000 unitĂ©s/heure)
Utiliser un critÚre monétaire dans le resolver de clé :
@Bean
public KeyResolver costAwareResolver() {
return exchange -> {
String path = exchange.getRequest().getPath().value();
int cost = path.contains("/recherche") ? 5 : 1;
return Mono.just(path + ":" + cost);
};
}4. Faux positifs avec les health checks
Les sondes Kubernetes (/actuator/health) ou les load balancers (AWS health check) passent sans quota. Ne pas les limiter sous peine de mettre hors-ligne les instances saines :
spring:
cloud:
gateway:
routes:
- id: health-unlimited
uri: lb://app-service
predicates:
- Path=/actuator/health/**
filters: []5. Logging des blocages
Sans logging, impossible de distinguer un pic lĂ©gitime dâune attaque. Ajouter un mĂ©trique structurĂ© :
@Component
public class RateLimitLogger {
private final Counter rateLimitedCounter;
public RateLimitLogger(MeterRegistry registry) {
this.rateLimitedCounter = Counter.builder("http.rate_limited.total")
.tag("endpoint", "api")
.register(registry);
}
public void logLimiteAtteinte(String endpoint, String identifiantClient) {
rateLimitedCounter.increment();
// Logging structuré supplémentaire si nécessaire
}
}Checklist de déploiement
- Stratégie choisie (sliding window, token bucket, etc.)
- Quota et fenĂȘtre calibrĂ©s selon le type de route
- Clé de comptage définie (IP, user, app-key, combinaison)
- Store distribué (Redis) si déploiement multi-instance
- Headers
X-RateLimit-*etRetry-Afterajoutés aux réponses - Réponse 429 structurée avec code erreur machine
- Health checks exclus du rate limit
- Logging / mĂ©trique des requĂȘtes limitĂ©es
- Tests fonctionnels du blocage écrits et effectués
- Documentation des quotas communiquée aux consommateurs API