Limiter les appels à son API REST avec Bucket4j

Publié le 21/07/2025 Source : sfeir.dev

Une API REST bien conçue est comme une porte d’entrée : elle doit être accessible, mais pas grande ouverte. Trop d’appels, trop fréquents, peuvent nuire à la stabilité du service, qu’ils soient le fait d’un usage abusif ou simplement de clients mal configurés. Pour prévenir ces situations, on utilise ce qu’on appelle le rate limiting : une limitation du nombre de requêtes acceptées sur une période donnée.

Dans cet article, nous allons découvrir Bucket4j, une bibliothèque Java qui offre une solution pour implémenter ce mécanisme.

Pourquoi mettre en place des rate limits ?

Instaurer une limitation de débit sur les appels API n’est pas une option, c’est une bonne pratique fondamentale. Voici pourquoi :

Présentation de Bucket4j

Bucket4j est une bibliothèque Java légère qui permet de gérer des quotas de requêtes selon le principe du token bucket :

Avantages de Bucket4j :

Implémentation “naïve”

Commençons par une implémentation en mémoire. Elle repose sur un endpoint

Le contrôleur HelloController

@RestController
@RequestMapping("/hello")
public class HelloController {

    private final Bucket bucket;


    public HelloController() {
        // refillGreedy : recharge le bucket d'un coup à intervalle régulier (ici, 20 tokens toutes les heures).
        Bandwidth limit = Bandwidth
                .builder()
                .capacity(20)
                .refillGreedy(20, Duration.ofHours(1))
                .build();
        this.bucket = Bucket.builder()
                .addLimit(limit)
                .build();
    }
    
    @GetMapping
    public ResponseEntity<String> sayHello() {
        if (bucket.tryConsume(1)) {
            return ResponseEntity.ok("Hello world!");
        } else {
            return ResponseEntity.status(429).body("Trop de requêtes pour cette clé API, merci de réessayer plus tard.");
        }
    }
}

Exemple d’implémentation en mémoire avec la stratégie refillGreedy

Que fait ce code, concrètement ?

Implémentation “naïve++”

Toujours avec une implémentation en mémoire, mais ici nous utiliserons une clé d’API pour notre endpoint :

Le contrôleur HelloController

@RestController
@RequestMapping("/hello")
public class HelloController {

    private final BucketService bucketService;

    public HelloController(BucketService bucketService) {
        this.bucketService = bucketService;
    }

    @GetMapping("/apikey")
    public ResponseEntity<String> sayHelloApiKey(@RequestHeader(value = "X-API-KEY", required = false) String apiKey) {
        if (apiKey == null || apiKey.isBlank()) {
            return ResponseEntity.badRequest().body("Clé API manquante dans le header X-API-KEY");
        }
        Bucket bucket = bucketService.resolveBucket(apiKey);
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            return ResponseEntity.ok()
                .header("X-Rate-Limit-Remaining", Long.toString(probe.getRemainingTokens()))
                .body("Hello world! (API key: " + apiKey + ")");
        }
        long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
            .header("X-Rate-Limit-Retry-After-Seconds", String.valueOf(waitForRefill))
            .body("Trop de requêtes pour cette clé API, merci de réessayer plus tard.");
    }
}

Exemple de restriction par clé d’API

Le service BucketService

Ce service s’occupe d’attribuer un bucket en mémoire à chaque clé API :

@Service
public class BucketService {
    private final Map<String, Bucket> buckets = new HashMap<>();

    public Bucket resolveBucket(String apiKey) {
        // Exemple : deux types de rate limit selon la clé
        if ("VIP-123".equals(apiKey)) {
            return buckets.computeIfAbsent(apiKey, k -> Bucket.builder()
                    .addLimit(Bandwidth.builder().capacity(100).refillGreedy(100, Duration.ofHours(1)).build())
                    .build());
        } else {
            return buckets.computeIfAbsent(apiKey, k -> Bucket.builder()
                    .addLimit(Bandwidth.builder().capacity(10).refillGreedy(10, Duration.ofHours(1)).build())
                    .build());
        }
    }
}

Que fait ce code, concrètement ?

Les 2 exemples ci-dessus sont dits naïfs car, dans le premier cas, nous devrons dupliquer le code du bucket dans chaque controller, et dans le second cas, nous aurons une forte dépendance au BucketService qui sera présent dans tous nos controller, ce qui est loin d’être optimal.

Implémentation avec Spring AOP

Plutôt que d’intégrer la logique de limitation directement dans chaque contrôleur ou d’appeler systématiquement un service, une autre approche consiste à utiliser Spring AOP. Cela permet de centraliser la vérification du quota et de l’appliquer à n’importe quelle méthode via une simple annotation.

l’annotation @RateLimited

Nous définissons une annotation personnalisée, qui pourra être appliquée sur toute méthode des controller.
Elle permet de spécifier une stratégie de limitation : "api-key""ip", ou "global" (valeur par défaut).

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
    /**
     * Type de limite à appliquer : par IP, par API key, etc.
     * Cela permettra d’adapter la logique dans l’aspect.
     */
    String strategy() default "global";
}

L’aspect RateLimitingAspect

Cet aspect intercepte les appels aux méthodes annotées avec @RateLimited. Il extrait la stratégie, détermine une clé de limitation (en fonction de l’adresse IP ou d’un header), vérifie le quota, et ajoute à la réponse HTTP l’information sur le nombre de jetons restants.

@Aspect
@Component
public class RateLimitingAspect {

    private final BucketService bucketService;

    public RateLimitingAspect(BucketService bucketService) {
        this.bucketService = bucketService;
    }

    @Around("@annotation(RateLimited)")
    public Object checkRateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();

        // Récupération de l'annotation et de la stratégie
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RateLimited rateLimited = method.getAnnotation(RateLimited.class);
        String strategy = rateLimited.strategy();

        // Détermination de la clé selon la stratégie
        String key;
        switch (strategy) {
            case "ip" -> key = request.getRemoteAddr();
            case "api-key" -> {
                String apiKey = request.getHeader("X-API-KEY");
                if (apiKey == null || apiKey.isBlank()) {
                    return ResponseEntity.badRequest().body("Clé API manquante dans le header X-API-KEY");
                }
                key = apiKey;
            }
            default -> key = "global";
        }

        Bucket bucket = bucketService.resolveBucket(key);
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            if (response != null) {
                response.setHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
            }
            return joinPoint.proceed();
        }

        long wait = probe.getNanosToWaitForRefill() / 1_000_000_000;
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
                .header("X-Rate-Limit-Retry-After-Seconds", String.valueOf(wait))
                .body("Quota dépassé, merci de réessayer dans " + wait + " secondes.");

    }
}

Service BucketService

Le service suivant centralise la création et le stockage des buckets selon la clé identifiée par l’aspect. Il applique des quotas différenciés : par exemple, une clé VIP-123 bénéficie d’un quota plus généreux.

@Service
public class BucketService {

    private final Map<String, Bucket> buckets = new HashMap<>();

    /**
     * Résout un bucket pour une clé donnée, quelle que soit son origine (IP, clé API, etc.).
     */
    public Bucket resolveBucket(String key) {
        // Exemple : si la clé est "VIP-123", quota plus généreux
        if ("VIP-123".equals(key)) {
            return buckets.computeIfAbsent(key, k ->
                    Bucket.builder()
                            .addLimit(Bandwidth.builder()
                                    .capacity(100)
                                    .refillGreedy(100, Duration.ofHours(1))
                                    .build())
                            .build());
        } else {
            return buckets.computeIfAbsent(key, k ->
                    Bucket.builder()
                            .addLimit(Bandwidth.builder()
                                    .capacity(10)
                                    .refillGreedy(10, Duration.ofHours(1))
                                    .build())
                            .build());
        }
    }
}

Le contrôleur HelloController

Voici comment appliquer le rate limiting sur des endpoints avec différentes stratégies :

@RestController
@RequestMapping("/hello")
public class HelloController {

    @GetMapping
    @RateLimited
    public ResponseEntity<String> sayHello() {
        return ResponseEntity.ok("Hello world!");
    }

    @GetMapping("/by-ip")
    @RateLimited(strategy = "ip")
    public ResponseEntity<String> helloByIp() {
        return ResponseEntity.ok("Hello avec limitation par IP !");
    }

    @GetMapping("/apikey")
    @RateLimited(strategy = "api-key")
    public ResponseEntity<String> sayHelloApiKey(@RequestHeader(value = "X-API-KEY", required = false) String apiKey) {
        return ResponseEntity.ok()
                .body("Hello world! (API key: " + apiKey + ")");

    }
}

✅ Avantages

⚠️ Inconvénients

Implémentation avec OncePerRequestFilter

Une autre manière de limiter le nombre d’appels à une API consiste à intercepter directement les requêtes HTTP avant qu’elles n’atteignent les contrôleurs. Spring fournit pour cela une classe utilitaire, OncePerRequestFilter, qui garantit qu’un filtre ne s’exécutera qu’une seule fois par requête.

Cette méthode permet une application transversale du rate limiting, tout en laissant la possibilité d’exclure certaines routes (documentation, endpoints techniques) grâce à une liste blanche configurable.

Le filtre RateLimitingFilter

Le filtre ci-dessous applique une politique de limitation à toutes les requêtes, sauf celles explicitement whitelistées (Swagger, Actuator…). Il exige la présence du header X-API-KEY, et utilise cette clé pour déterminer le quota applicable.

@Component
public class RateLimitingFilter extends OncePerRequestFilter {

    private final BucketService bucketService;
    // Liste des préfixes d'URI à whitelister (modifiable facilement)
    private static final Set<String> WHITELIST_PATTERNS = Set.of(
        "/actuator/**",
        "/swagger-ui/**",
        "/v3/api-docs/**"
    );
    private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();

    public RateLimitingFilter(BucketService bucketService) {
        this.bucketService = bucketService;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return WHITELIST_PATTERNS.stream().anyMatch(pattern -> PATH_MATCHER.match(pattern, uri));
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String apiKey = request.getHeader("X-API-KEY");
        if (apiKey == null || apiKey.isBlank()) {
            response.setStatus(HttpStatus.BAD_REQUEST.value());
            response.getWriter().write("Clé API manquante (header X-API-KEY).");
            return;
        }
        Bucket bucket = bucketService.resolveBucket(apiKey);

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            response.setHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
            filterChain.doFilter(request, response);
        } else {
            long wait = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setHeader("X-Rate-Limit-Retry-After-Seconds", String.valueOf(wait));
            response.getWriter().write("Quota dépassé. Merci de réessayer dans " + wait + " secondes.");
        }
    }
}

Exemple via un Filter

Ce que fait ce code

Avantages

Inconvénients

Conclusion : quelle approche choisir pour limiter les appels à votre API ?

À travers cet article, nous avons exploré trois façons d’implémenter un système de rate limiting avec Bucket4j dans une application Spring Boot.

Chaque approche a ses mérites et répond à des besoins spécifiques. En voici un résumé :

Approche Simplicité Réutilisabilité Personnalisation Centralisation Ciblage fin
Naïve dans le contrôleur ✅ Très simple ❌ Faible ✅ Possible ❌ Répétitive ✅ Méthode par méthode
Avec Spring AOP ⚠️ Demande un peu plus de configuration ✅ Élevée ✅ Forte ✅ Oui ✅ Méthode par méthode
Via un filtre HTTP ✅ Directe ✅ Moyenne ⚠️ Limitée (stratégie figée) ✅ Globale ❌ Pas spécifique à une méthode

Quand utiliser quelle approche ?

Et après ? Des pistes pour aller plus loin

Ce que nous avons présenté ici constitue une base solide, mais reste une implémentation monolithique et en mémoire. Pour une montée en charge ou un déploiement distribué, plusieurs pistes s’ouvrent :