Skip to Content
BackendRate Limiting & Throttling

Rate Limiting & Throttling

Protéger une API contre la surconsommation : stratégies, implémentation Spring Boot / Micronaut / Gateway, headers, et piÚges.

Pourquoi limiter ?

MotivImpact sans rate limit
Protection DDoSÉpuisement des threads, arrĂȘt du service
Fair useUn client monopolise la bande passante
Protection backendLes bases de données et caches sont fragiles sous charge
ContrĂŽle des coĂ»tsLes appels API externes (paiement Ă  l’usage) filent en flĂšche
Qualité de serviceLatence 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égiePrincipeComportement
Fixed windowCompteur par intervalle fixe (ex. 60 s)CrĂȘtes Ă  la bordure de l’intervalle
Sliding windowFenĂȘtre glissante calquĂ©e sur le temps rĂ©elRĂ©partition uniforme, plus lisse
Token bucketStock de N tokens, un par requĂȘte, rĂ©gĂ©nĂ©ration lenteAutorise les bursts courts puis rĂ©gule
Leaky bucketFile d’attente, service Ă  dĂ©bit constantLissage parfait mais latence accrue

Choix rapide : sliding-window pour le fair-use classique, token-bucket quand 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) :

HeaderSignification
X-RateLimit-LimitNombre maximum d’appels dans la fenĂȘtre
X-RateLimit-RemainingAppels restants avant blocage
X-RateLimit-ResetHeure (Unix epoch) de réinitialisation du compteur
Retry-AfterSecondes 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: true

Annotation @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 : @RateLimiter de Resilience4j utilise un Semaphore local Ă  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: 1

Ré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, userKeyResolver renvoie Mono.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: 100

ImplĂ©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: 0s

Annotation @RateLimiter

@Controller("/api/utilisateurs") public class UtilisateurController { @Get(uri = "/", swallows429 = true) @RateLimiter(name = "public-api") public List<UtilisateurDTO> lister() { return utilisateurService.lister(); } }

Note : swallows429 = true fait que Micronaut gĂ©nĂšre automatiquement la rĂ©ponse 429. Sans ce flag, l’exception RateLimiterExhaustedException remonte 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: 60

PiĂš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-For dans 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 de X-Forwarded-For uniquement 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-* et Retry-After ajoutĂ©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