Concevoir un rate limiter pour API gateway avec Redis et Spring Cloud Gateway

1. Vue d’ensemble

Quand plusieurs clients appellent simultanément l’API, un client défaillant ou qui spam intentionnellement peut faire tomber le backend de tout le monde. Un rate limiter au niveau de l’API gateway bloque les requêtes qui dépassent la limite avant qu’elles n’atteignent le service en aval.

Cet article parcourt une étude de cas complète. Les étapes comprennent la spécification des exigences, le choix d’un algorithme, la conception des données et de l’API, puis l’implémentation avec des scripts Lua Redis et Spring Cloud Gateway. Après lecture, vous serez capable de construire un rate limiter distribué et de gérer les conditions de course, la dérive d’horloge, les hot keys et les défaillances Redis.

2. Spécifications

2.1. Exigences fonctionnelles

  • Limiter par clé API du tenant et par route. Par exemple : le tenant shop-a peut appeler POST /orders au maximum 100 requêtes/seconde, avec un burst autorisé jusqu’à 200.
  • Les requêtes qui dépassent la limite reçoivent 429 Too Many Requests avec un header Retry-After.
  • Chaque réponse inclut un header X-RateLimit-Remaining pour que le client puisse s’autorréguler.
  • Les règles peuvent être modifiées à l’exécution sans avoir besoin de redéployer la gateway.

2.2. Échelle et contraintes

  • Environ 50 000 RPS aux heures de pointe, 20 instances de gateway, 100 000 tenants.
  • La latence ajoutée par requête doit être inférieure à 2 ms au p99.
  • La limite doit être correcte sur l’ensemble du cluster, non comptée individuellement par instance. Si la limite est 100/s, le cluster dans son ensemble doit laisser passer environ 100/s.
  • Le rate limiter ne doit pas devenir un point de défaillance unique. Quand Redis est défaillant, la gateway doit continuer à fonctionner.
  • Accepter une petite marge d’erreur (quelques %) en échange de meilleures performances.

La troisième contrainte élimine l’approche de comptage en mémoire locale par instance. Nous avons besoin d’un magasin d’état partagé. Redis est le choix naturel grâce à sa faible latence et ses opérations atomiques via Lua.

3. Choix d’algorithme

Algorithme Avantages Inconvénients
Fenêtre fixe Simple, une seule opération INCR Burst doublé aux limites de la fenêtre
Journal glissant Parfaitement exact Consomme de la mémoire, stocke chaque timestamp
Compteur de fenêtre glissante Approximation légère Difficile de représenter les bursts
Token bucket Support naturel des bursts, seulement 2 champs Nécessite un calcul de remplissage de tokens

Nous choisissons token bucket. Chaque bucket a une capacity (taille du burst) et un taux de remplissage refillPerSecond. Chaque requête consomme un token. Quand les tokens sont épuisés, la requête est rejetée. L’état n’a que deux nombres : tokens et ts (dernière mise à jour). Avec 100 000 tenants multiplié par plusieurs routes, la capacité totale est juste quelques dizaines de MB.

4. Modèle de données

4.1. Règles de configuration en SQL

Les règles changent rarement et doivent être gérées, donc nous les stockons dans PostgreSQL :

CREATE TABLE rate_limit_rule (
    id                 BIGSERIAL PRIMARY KEY,
    tenant_id          VARCHAR(64)  NOT NULL,
    route_id           VARCHAR(64)  NOT NULL,
    capacity           INT          NOT NULL CHECK (capacity > 0),
    refill_per_second  INT          NOT NULL CHECK (refill_per_second > 0),
    updated_at         TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, route_id)
);

INSERT INTO rate_limit_rule (tenant_id, route_id, capacity, refill_per_second)
VALUES ('shop-a', 'create-order', 200, 100);

La gateway ne lit pas cette table à chaque requête. Chaque instance charge toutes les règles en mémoire et les rafraîchit périodiquement, par exemple toutes les 30 secondes. Une autre approche consiste à écouter l’événement rule-changed via Kafka. Le chemin critique (hot path) ne touche jamais la base de données.

4.2. État du bucket dans Redis

Chaque paire tenant-route est un hash Redis :

Key:    rl:{shop-a}:create-order
Fields: tokens = 153.4
        ts     = 1760256000123   (ms, selon l'horloge Redis)
TTL:    expire automatiquement si le bucket plein n'est pas utilisé

La partie {shop-a} est une hash tag. Sur Redis Cluster, toutes les clés d’un même tenant tombent dans le même slot. Si plus tard vous devez vérifier plusieurs buckets dans un script, ils seront sur le même nœud.

5. API et flux de traitement

Le client voit deux types de réponses :

HTTP/1.1 200 OK
X-RateLimit-Remaining: 152

HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Remaining: 0

Le flux de traitement dans la gateway :

  1. Récupérer X-Api-Key pour identifier le tenant et obtenir l’ID de la route depuis la route correspondante.
  2. Chercher la règle dans le cache mémoire. S’il n’y a pas de règle, utiliser la règle par défaut.
  3. Appeler le script Lua sur Redis pour remplir les tokens et en obtenir dans un seul round-trip atomique.
  4. Si autorisé, ajouter un header et transférer. Si refusé, retourner 429.
  5. Si Redis échoue ou timeout, basculer sur le limiter local (section 7.3).

6. Implémentation

6.1. Dépendances

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2025.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
    </dependency>
</dependencies>

6.2. Script Lua token bucket

Placer le fichier à src/main/resources/scripts/token_bucket.lua :

local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillMs = tonumber(ARGV[2]) / 1000.0   -- token par ms

local t   = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local data   = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts     = tonumber(data[2]) or now

tokens = math.min(capacity, tokens + math.max(0, now - ts) * refillMs)

local allowed = 0
if tokens >= 1 then
  tokens = tokens - 1
  allowed = 1
end

redis.call('HSET', key, 'tokens', tostring(tokens), 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / refillMs) * 2)

return { allowed, math.floor(tokens) }

Points clés :

  • redis.call('TIME') récupère l’heure de Redis au lieu de celle de la gateway. 20 instances ont des horloges qui peuvent dériver de plusieurs dizaines de ms, donc si chaque instance envoie son propre now, les tokens seraient rechargés incorrectement. À partir de Redis 7, les scripts sont répliqués par défaut selon la réplication des effets (effects replication), donc appeler TIME avant d’écrire est sûr.
  • La séquence complète de lecture, calcul et écriture s’exécute de manière atomique, car Redis exécute les scripts en un seul thread. C’est la solution aux conditions de course entre instances.
  • PEXPIRE vaut deux fois le temps de remplissage du bucket. Un bucket inactif disparaît automatiquement. Quand il réapparaît, il recommence plein, exactement comme prévu.
  • Le script retourne [allowed, remaining]. Lua convertit les nombres réels en entiers lors du retour, donc nous utilisons floor d’abord pour plus de clarté.

6.3. Appeler le script depuis Java

public record RateLimitRule(int capacity, int refillPerSecond) {}

public record Decision(boolean allowed, long remaining) {}

@Component
public class RedisTokenBucket {

    private final ReactiveStringRedisTemplate redis;
    private final RedisScript<List> script =
            RedisScript.of(new ClassPathResource("scripts/token_bucket.lua"), List.class);

    public RedisTokenBucket(ReactiveStringRedisTemplate redis) {
        this.redis = redis;
    }

    @SuppressWarnings("unchecked")
    public Mono<Decision> tryAcquire(String tenant, String route, RateLimitRule rule) {
        String key = "rl:{" + tenant + "}:" + route;
        return redis.execute(script, List.of(key),
                        List.of(String.valueOf(rule.capacity()),
                                String.valueOf(rule.refillPerSecond())))
                .next()
                .map(r -> {
                    List<Long> res = (List<Long>) r;
                    return new Decision(res.get(0) == 1L, res.get(1));
                });
    }
}

Spring calcule automatiquement le SHA1 du script et utilise EVALSHA. Si Redis n’a pas encore en cache le script, Spring retombe sur EVAL. Ainsi, seul le premier appel transmet le contenu complet du script sur le réseau.

6.4. Filtre global dans la gateway

@Component
public class RateLimitFilter implements GlobalFilter, Ordered {

    private static final RateLimitRule DEFAULT_RULE = new RateLimitRule(20, 10);

    private final RedisTokenBucket redisBucket;
    private final LocalFallbackLimiter fallback;
    private final RuleCache rules; // Map<String, RateLimitRule> chargée depuis la DB périodiquement

    public RateLimitFilter(RedisTokenBucket redisBucket,
                           LocalFallbackLimiter fallback, RuleCache rules) {
        this.redisBucket = redisBucket;
        this.fallback = fallback;
        this.rules = rules;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String tenant = exchange.getRequest().getHeaders().getFirst("X-Api-Key");
        if (tenant == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
        String routeId = route != null ? route.getId() : "default";
        RateLimitRule rule = rules.find(tenant, routeId).orElse(DEFAULT_RULE);

        return redisBucket.tryAcquire(tenant, routeId, rule)
                .timeout(Duration.ofMillis(5))
                .onErrorResume(e -> Mono.just(fallback.tryAcquire(tenant, routeId, rule)))
                .flatMap(decision -> {
                    var response = exchange.getResponse();
                    response.getHeaders().set("X-RateLimit-Remaining",
                            String.valueOf(decision.remaining()));
                    if (decision.allowed()) {
                        return chain.filter(exchange);
                    }
                    response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
                    response.getHeaders().set("Retry-After",
                            String.valueOf(Math.max(1, 1 / rule.refillPerSecond())));
                    return response.setComplete();
                });
    }

    @Override
    public int getOrder() {
        return -100; // s'exécute après le match de route, avant le filtre de routage
    }
}

Le filtre s’exécute pour toutes les routes. Il définit timeout(5ms) pour qu’un nœud Redis lent ne traîne pas l’ensemble de la gateway. Quand Redis échoue, onErrorResume bascule sur le limiter local. Testez rapidement avec curl avec la règle capacity=2 :

for i in 1 2 3; do
  curl -s -o /dev/null -w "%{http_code}\n" -H "X-Api-Key: shop-a" -X POST localhost:8080/orders
done
200
200
429

7. Points difficiles et solutions

7.1. Conditions de course entre instances

L’approche naïve consiste à appeler HGET, calculer en Java puis appeler HSET. Deux instances peuvent lire tokens=1 et laisser toutes les deux passer la requête. Le script Lua élimine complètement ce race condition. L’approche WATCH/MULTI est aussi correcte, mais nécessite des retries lors de conflits et consomme plus de round-trips.

7.2. Hot key et pics de charge

Un grand tenant peut envoyer 20 000 RPS sur une clé. Un nœud Redis peut gérer environ 100 000 ops/s, mais cette clé devient un hot spot. Il y a deux solutions :

  • Allocation de tokens par lot (pré-chargement local) : chaque instance demande d’avance un lot de tokens, par exemple 50 tokens à la fois, puis les consomme localement. Le nombre d’appels Redis diminue d’environ 50 fois. En contrepartie, la limite totale peut être dépassée de nombre instances × taille du lot dans une courte période. Avec l’exigence de « quelques % d’erreur » à la section 2.2, c’est un niveau acceptable.
  • Partitionnement de bucket (sharding) : diviser la limite 20 000/s en 10 sous-buckets rl:{shop-a}:create-order:0..9, chacun avec 2 000/s. Les requêtes choisissent un sous-bucket aléatoirement. Supprimer la hash tag de ces clés pour les répartir sur plusieurs nœuds.

Les bursts légitimes sont déjà absorbés par capacity. C’est exactement pour cela que nous avons choisi token bucket plutôt que fixed window.

7.3. Redis indisponible : fail-open contrôlé

Fail-closed, c’est-à-dire rejeter toutes les requêtes quand Redis est mort, transforme une panne d’infrastructure secondaire en panne du système entier. Fail-open complètement perd la couche de protection. La solution intermédiaire est un limiter local avec la limite divisée également par le nombre d’instances :

@Component
public class LocalFallbackLimiter {

    private final int instanceCount;
    private final LongSupplier clock;
    private final ConcurrentHashMap<String, LocalBucket> buckets = new ConcurrentHashMap<>();

    public LocalFallbackLimiter(@Value("${gateway.instances:20}") int instanceCount) {
        this(instanceCount, System::currentTimeMillis);
    }

    LocalFallbackLimiter(int instanceCount, LongSupplier clock) {
        this.instanceCount = instanceCount;
        this.clock = clock;
    }

    public Decision tryAcquire(String tenant, String route, RateLimitRule rule) {
        double cap = Math.max(1, rule.capacity() / (double) instanceCount);
        double perMs = rule.refillPerSecond() / (double) instanceCount / 1000.0;
        LocalBucket b = buckets.computeIfAbsent(tenant + ":" + route,
                k -> new LocalBucket(cap, clock.getAsLong()));
        return b.take(cap, perMs, clock.getAsLong());
    }

    static final class LocalBucket {
        private double tokens;
        private long ts;

        LocalBucket(double tokens, long ts) {
            this.tokens = tokens;
            this.ts = ts;
        }

        synchronized Decision take(double cap, double perMs, long now) {
            tokens = Math.min(cap, tokens + Math.max(0, now - ts) * perMs);
            ts = now;
            if (tokens >= 1) {
                tokens -= 1;
                return new Decision(true, (long) tokens);
            }
            return new Decision(false, 0);
        }
    }
}

Chaque instance détient 1/instanceCount de la limite. Le total du cluster reste approximativement correct sans Redis. En environnement production, vous devez envelopper l’appel Redis avec un circuit breaker (par exemple Resilience4j). Quand Redis est mort, le circuit breaker évite de dépenser 5 ms de timeout par requête.

7.4. Cohérence multi-région

Si la gateway s’exécute dans deux régions, synchroniser Redis à travers les régions pour chaque requête violera l’exigence de 2 ms. L’approche pragmatique est de partitionner la limite par région selon la proportion du trafic, par exemple 70/30, et d’accepter qu’un tenant puisse être rejeté tôt dans une région bien que l’autre ait du quota restant. Pour plus d’optimisation, un job en arrière-plan peut ajuster la proportion toutes les quelques minutes en fonction des métriques réelles.

8. Tests

La logique du token bucket doit être testée indépendamment de l’horloge réelle. Puisque LocalFallbackLimiter prend LongSupplier, nous avançons le temps avec AtomicLong :

class LocalFallbackLimiterTest {

    @Test
    void shouldRejectWhenBucketEmptyAndRefillOverTime() {
        AtomicLong now = new AtomicLong(0);
        var limiter = new LocalFallbackLimiter(1, now::get);
        var rule = new RateLimitRule(2, 1); // burst 2, recharge 1 token/seconde

        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isFalse();

        now.addAndGet(1_000); // après 1 seconde, 1 token supplémentaire

        Decision d = limiter.tryAcquire("shop-a", "create-order", rule);
        assertThat(d.allowed()).isTrue();
        assertThat(d.remaining()).isZero();
    }
}

Ce test confirme trois comportements : les bursts autorisés jusqu’à capacity, les requêtes au-delà sont rejetées, et les tokens sont rechargés correctement dans le temps. Pour le script Lua, vous devriez écrire un test d’intégration avec Testcontainers utilisant l’image redis:7. Envoyez 1 000 requêtes en parallèle et vérifiez que le nombre de requêtes autorisées ne dépasse pas capacity.

Note : Quelques erreurs courantes :

  • Utiliser System.currentTimeMillis() de la gateway comme now dans le script. La dérive d’horloge entre instances conduira à un mauvais rechargement des tokens.
  • Oublier de définir le TTL pour les clés. Redis gonflera graduellement avec le nombre de tenants qui apparaissent.
  • Identifier les clients par IP après NAT ou load balancer. Toute une entreprise partagerait le même bucket.
  • Ne pas utiliser le rate limiter de la gateway à la place des mécanismes anti-surcharge dans le service lui-même (bulkhead, load shedding). La gateway ne connaît que le nombre de requêtes, pas lesquelles sont lourdes.

9. Conclusion

Dans cet article, nous avons conçu un rate limiter pour API gateway du cahier des charges à l’implémentation. Token bucket gère proprement les bursts. Le script Lua sur Redis résout les conditions de course avec un seul round-trip atomique. Récupérer l’heure depuis Redis élimine le problème de dérive d’horloge entre instances.

Les vraies difficultés résident au-delà de l’algorithme. Les hot keys nécessitent une allocation par lot ou du partitionnement de bucket. La défaillance de Redis nécessite un fail-open contrôlé avec un limiter local. Les systèmes multi-régions nécessitent de partitionner les limites plutôt que de synchroniser strictement. À chaque point, nous échangons un peu de précision pour obtenir une latence basse et une disponibilité, conforme aux contraintes établies dès le départ.

system-designrate-limitingredisspring-cloud-gatewayjava