Skip to main content

Rate Limiting Spring Boot APIs with Bucket4j and Redis: Per-User, Per-Key and Global Limits

Build rate limiting into a Spring Boot 4.1 API with Bucket4j 8.21 and Redis: per-user, per-API-key and global token buckets in a servlet filter, 429 with Retry-After and X-RateLimit headers, two instances sharing one bucket, and the failures worth reproducing (limit changes that do nothing, clock skew, Redis down), all with tests that assert it.

One day a single client script starts calling your /api/search endpoint in a loop. Nothing is broken — every response is a correct 200 — but the database is busy, the other customers are slow, and the loop has no reason to stop. A rate limit is the answer to that: the server says “you may make this many requests this fast, and no more,” and replies 429 Too Many Requests to the rest. This post builds one in Spring Boot 4.1 with Bucket4j, from the idea (a bucket of tokens) up to something you could run behind a load balancer: limits per user, per API key and for the whole service, a 429 with Retry-After and X-RateLimit-* headers, buckets kept in Redis so two application instances share them, and tests that assert all of it. Everything quoted below was run; each console block is a verbatim excerpt of a file in the companion module rate-limit, which regenerates all of them with ./scripts/run-all.sh.
Versions this was tested against. Spring Boot 4.1.2 (the newest GA in Maven Central’s maven-metadata.xml; 4.1.0 went GA on 10 June 2026, and 4.2.0-RC1 is a release candidate, not GA), Bucket4j 8.21.0 (artifacts bucket4j_jdk17-core and bucket4j_jdk17-lettuce, published 2 October 2026), Lettuce 7.5.2.RELEASE (the version Boot manages), JDK 25 (Temurin 25.0.5), and a real redis-server 7.0.15 started by the tests. Docker was not available where I wrote the module, so there is no Testcontainers; the tests launch the Redis binary themselves.

A rate limit is a bucket that refills at a fixed speed

Forget HTTP for a moment. Picture a bucket that holds at most 10 tokens. Every request must take one token out; if the bucket is empty, the request is refused. A tap drips a new token in every 6 seconds, and a full bucket just overflows. That is the whole token bucket algorithm, and it gives you two knobs that mean different things: the capacity is how big a burst a caller may spend at once, and capacity per refill period is the average rate they can keep up for ever. Ten tokens refilled at ten per minute means “10 requests whenever you like, but 10 per minute on average.”
One bucket: a capacity, and a rate at which it refillsrefill1 token every 6 scapacity 10, 7 tokens leftrequest arrivestake 1 tokentoken available200 OKbucket empty429 + Retry-After: 6The capacity is the burst a caller may spend at once; capacity divided by the refill period is the average rate they can sustain.
The two numbers that define a bucket. Seven of ten slots are filled because that is what the demo user alice would have left after three requests.
The picture is the argument: nothing in a token bucket counts requests per calendar minute, so there is no moment at 12:00:00 when every caller’s count resets and a thundering herd arrives together. A caller who stops for a minute simply finds a full bucket.

The smallest working bucket: no Spring, no Redis

Bucket4j’s in-memory bucket is the quickest way to feel the algorithm. The test below builds one with capacity 5 that refills 5 tokens every 10 seconds, on a clock the test controls so it never sleeps (TokenBucketBasicsTest).
            Bucket greedy = Bucket.builder().withCustomTimePrecision(clock)
                    .addLimit(l -> l.capacity(5).refillGreedy(5, Duration.ofSeconds(10))).build();
Six calls in the same instant, then waiting. The output is output/01-token-bucket-basics.txt:
call 1: consumed=true remaining=4 waitForRefill=0s
call 2: consumed=true remaining=3 waitForRefill=0s
call 3: consumed=true remaining=2 waitForRefill=0s
call 4: consumed=true remaining=1 waitForRefill=0s
call 5: consumed=true remaining=0 waitForRefill=0s
call 6: consumed=false remaining=0 waitForRefill=2s
-- greedy refill: tokens trickle back at 1 per 2 s
after 1 s: available=0
after 2 s: available=1
after 4 s: available=2
after 8 s: available=4
Two things to read off this. The sixth call is refused and getNanosToWaitForRefill() already knows the answer to “how long until I can try again” — two seconds, which is exactly what we will put in a Retry-After header. And the greedy refill is continuous: after 2 s one token is back, after 8 s four, because a fraction of a token is carried between calls.
Greedy or intervally? Bucket4j has two refill styles. refillGreedy drips tokens back continuously (the default in this module). refillIntervally adds nothing until the whole period has passed, then adds everything at once. The same test shows it: with a 10-second period the intervally bucket has 0 tokens at 9.999 s and 5 at 10.000 s (same file, last two lines). Intervally is what many people mean by “5 per 10 seconds”, and it allows a double burst across a boundary; greedy does not.
Going deeper: two limits on one bucket, and the cost of one token A bucket can carry more than one limit and a request must find a token in all of them. Burst 3 per second plus a ceiling of 10 per minute gives the behaviour below, from output/01b-burst-and-sustained.txt: three allowed per second until the minute ceiling bites, and nothing after it, even though the per-second limit keeps refilling.
== two limits: 3 per second AND 10 per minute (both must have a token)
second 0: 5 attempts, 3 allowed, running total 3
second 1: 5 attempts, 3 allowed, running total 6
second 2: 5 attempts, 3 allowed, running total 9
second 3: 5 attempts, 1 allowed, running total 10
second 4: 5 attempts, 0 allowed, running total 10
A call can also consume more than one token: tryConsume(5) charges an expensive endpoint five times a cheap one. We use that later for /api/report.

Putting a bucket in front of an endpoint: one servlet filter

To limit an API we need three decisions made on every request: who is calling (so each caller gets their own bucket), whether they have a token, and what to say if they do not. A servlet filter is the natural home: it runs before Spring MVC, so a refused request never reaches a controller, a message converter or a database.
Where the check sits: in a filter, before Spring MVCclientRateLimitFilter1 identify caller2 ask the limiterRateLimiterown bucket, thenglobal bucketRedisrl:user:alicerl:globalSpring MVCcontroller runs429Retry-Aftertoken takenno tokenA rejected request is answered inside the filter, so it never reaches a controller, a message converter or a database.
The filter asks the limiter; the limiter reads and writes small keys in Redis. A refusal is written by the filter itself.
The dependencies come first, and this is the first trap. Bucket4j 8.x renamed its artifacts to carry the JDK level, so the coordinates in older blog posts (bucket4j-core, bucket4j-redis) are not what you want: in Maven Central the newest bucket4j-core is 8.10.1, while bucket4j_jdk17-core is 8.21.0. The Java packages did not change (io.github.bucket4j.*). From pom.xml:
    <!-- Brings Lettuce (io.lettuce:lettuce-core). -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!-- The Bucket4j artifacts carry the JDK level in the artifactId: bucket4j_jdk17-*. -->
    <dependency>
      <groupId>com.bucket4j</groupId>
      <artifactId>bucket4j_jdk17-core</artifactId>
      <version>${bucket4j.version}</version>
    </dependency>
    <dependency>
      <groupId>com.bucket4j</groupId>
      <artifactId>bucket4j_jdk17-lettuce</artifactId>
      <version>${bucket4j.version}</version>
    </dependency>
Now the filter. This is the whole doFilterInternal from RateLimitFilter.java; the caller identification it relies on is shown two sections down.
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        Caller caller = identify(request);
        if (caller == null) {
            problem(response, 401, "Unauthorized", "Unknown API key", null);
            return;
        }
        int cost = props.costs().getOrDefault(request.getRequestURI(), 1);

        Decision decision;
        try {
            decision = limiter.check(caller, cost);
        } catch (RuntimeException ex) {
            if (props.failOpen()) {
                log.warn("Rate limiter unavailable, letting the request through: {}", ex.toString());
                response.setHeader("X-RateLimit-Status", "unavailable");
                chain.doFilter(request, response);
            } else {
                log.warn("Rate limiter unavailable, rejecting the request: {}", ex.toString());
                problem(response, 503, "Service Unavailable", "Rate limiter unavailable", null);
            }
            return;
        }

        response.setHeader("X-RateLimit-Limit", Long.toString(decision.limit()));
        response.setHeader("X-RateLimit-Remaining", Long.toString(decision.remaining()));
        response.setHeader("X-RateLimit-Reset", Long.toString(decision.resetSeconds()));
        response.setHeader("X-RateLimit-Scope", decision.scope());
        if (decision.allowed()) {
            chain.doFilter(request, response);
        } else {
            response.setHeader("Retry-After", Long.toString(decision.retryAfterSeconds()));
            problem(response, 429, "Too Many Requests",
                    "Rate limit exceeded for scope '" + decision.scope() + "'. Retry in "
                            + decision.retryAfterSeconds() + " second(s).", decision.retryAfterSeconds());
        }
    }
Run it and send twelve requests as the same user (output/02-per-user.txt; limit is X-RateLimit-Limit, and so on):
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=9 reset=6 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=8 reset=12 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=7 reset=18 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=6 reset=24 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=5 reset=30 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=4 reset=36 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=3 reset=42 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=2 reset=48 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=1 reset=54 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=0 reset=60 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 429  limit=10 remaining=0 reset=60 retry-after=6 scope=user
GET /api/hello  X-User-Id: alice -> 429  limit=10 remaining=0 reset=60 retry-after=6 scope=user
Ten allowed, then 429 with Retry-After: 6 — ten tokens refilled over a minute is one token every six seconds, and the response says so. Another user is untouched, because alice and bob are different keys (same transcript, output/02-per-user.txt):
== alice is out of tokens; bob is a different bucket
GET /api/hello  X-User-Id: alice -> 429  limit=10 remaining=0 reset=60 retry-after=6 scope=user
GET /api/hello  X-User-Id: bob   -> 200  limit=10 remaining=9 reset=6 retry-after=null scope=user
Going deeper: why a filter, and where in the chain A filter, not a HandlerInterceptor, because the filter sees every request including ones no controller matches (a 404 flood is still load), and because it can answer without any MVC machinery. The cost is that a filter cannot easily read annotations on the handler method, which is why this module keeps its per-path costs in application.yml rather than on the controller. The filter here is a plain @Component, which Spring Boot registers in the servlet container; I did not run this module together with Spring Security, so where it sits relative to the security filter chain is a decision I describe below but did not exercise. If the limiter runs before authentication, the only identity it has is an address or an API key header, but failed logins are counted. If it runs after, it can key on the authenticated user but brute-force attempts that fail authentication never touch a bucket. Many services use two limiters for exactly that reason.

What a good 429 tells the client

A bare 429 makes a well-behaved client guess. The response should answer the question it will ask next: when can I retry? HTTP has a standard header for that, Retry-After (defined in RFC 9110), and RFC 6585 defines the 429 status itself. This module sends the header as whole seconds, rounded up and never zero: rounding down would send a client back a moment too early and earn it a second 429. The X-RateLimit-* headers are different: there is no standard behind them. Services pick the same names and disagree on the details (is reset a timestamp or a number of seconds?). This module sends Limit (capacity of the bucket that decided), Remaining, Reset (seconds until that bucket is full again), and a Scope header of my own naming that says which bucket answered. The body is application/problem+json, the media type of RFC 9457, which the ProblemDetail post covers in depth. The 429 from the run above, byte for byte (output/02-per-user.txt):
-- body of the 429:
{"type":"about:blank","title":"Too Many Requests","status":429,"detail":"Rate limit exceeded for scope 'user'. Retry in 6 second(s).","retryAfterSeconds":6}
The test that proves Retry-After is honest waits exactly what the header says on the test clock. One second short is still refused; the full wait is allowed (output/02-per-user.txt):
== alice waits exactly Retry-After (6 s on the test clock) and tries again
GET /api/hello  X-User-Id: alice -> 429  limit=10 remaining=0 reset=60 retry-after=6 scope=user
-- after 5 s: still too early
GET /api/hello  X-User-Id: alice -> 429  limit=10 remaining=0 reset=55 retry-after=1 scope=user
-- after 6 s in total:
GET /api/hello  X-User-Id: alice -> 200  limit=10 remaining=0 reset=60 retry-after=null scope=user
GET /api/hello  X-User-Id: alice -> 429  limit=10 remaining=0 reset=60 retry-after=6 scope=user
One more header you may meet. The IETF HTTP API working group has an Internet-Draft, draft-ietf-httpapi-ratelimit-headers, that defines RateLimit and RateLimit-Policy fields. When I checked the datatracker it was at revision 11 (May 2026) and still a draft, not an RFC, so this module does not send them. If you build clients against your own API, nothing stops you adding them next to the X- names.
Going deeper: a header is a promise, not an enforcement Only Retry-After carries meaning a generic HTTP client library understands. Everything else is documentation for humans and for your own SDKs. The limit is enforced on the server by the bucket whether the client reads the headers or not — which is why the 429 body repeats the wait in retryAfterSeconds for clients that only parse JSON.

Why the bucket has to live in Redis

Everything so far kept the bucket in one JVM’s memory. The moment you run two instances behind a load balancer, each has its own bucket, and a caller whose requests alternate between them gets twice the limit. Three instances, three times. And it changes when autoscaling adds a pod. I measured the two-instance case with plain in-memory buckets (output/01c-per-instance-buckets.txt):
== the same 5-per-minute limit held in memory by two instances
12 requests alternating A, B: 10 allowed (the limit is 5)
Why the bucket has to live outside the JVMLimit of 5 per minute, one bucket per instanceinstance Aown bucket: 5instance Bown bucket: 512 requests, alternating A and Ballowed: 10 (transcript 01c)Limit of 5 per minute, one bucket in Redisinstance Ano bucket of its owninstance Bno bucket of its ownRedisrl:key:ea39d5800282edc47 requests, alternating A and Ballowed: 5 (transcript 05)With N instances and a local bucket each, the real limit is N times the number you configured, and it changes whenever autoscaling does.
Left: two local buckets of 5 let 10 requests through. Right: the same two instances sharing one bucket in Redis let 5 through.
Bucket4j’s answer is to keep the same bucket object but store its state in a shared place. In the Redis integration each bucket is one Redis key holding a small binary value; every instance reads the state, computes the new one, and writes it back only if nobody else changed it in the meantime (compare-and-swap, which the Lettuce module implements by running a script through Redis). The application code does not change: it talks to a ProxyManager and gets a BucketProxy that has the same tryConsumeAndReturnRemaining as the local bucket. The wiring is one class, RedisRateLimitConfig.java. It reuses the Lettuce client Spring Boot already built from spring.data.redis.*, so host, port, password and TLS settings apply to the rate limiter without a second set of properties:
    @Bean(destroyMethod = "close")
    StatefulRedisConnection<String, byte[]> rateLimitConnection(LettuceConnectionFactory factory) {
        RedisClient client = (RedisClient) factory.getRequiredNativeClient();
        StatefulRedisConnection<String, byte[]> connection =
                client.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
        connection.setTimeout(Duration.ofMillis(500));
        return connection;
    }

    /**
     * The proxy manager is the only Redis-aware object. Everything else talks to
     * {@link ProxyManager}, so the same code runs on Hazelcast, JDBC or Redisson by swapping this
     * bean.
     *
     * <p>The clock is a bean so a test can move time without sleeping. Production gets the system
     * clock.
     */
    @Bean
    ProxyManager<String> rateLimitProxyManager(StatefulRedisConnection<String, byte[]> connection,
                                               ObjectProvider<TimeMeter> clock) {
        return Bucket4jLettuce.casBasedBuilder(connection)
                // Delete a bucket's key once it has been full for 10 seconds: idle callers cost no memory.
                .expirationAfterWrite(
                        ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(10)))
                .clientClock(clock.getIfAvailable(() -> TimeMeter.SYSTEM_MILLISECONDS))
                .build();
    }
API drift worth knowing. I first wrote this against the builder methods I remembered, withExpirationStrategy(..) and withClientSideConfig(..), and it did not compile. javap on bucket4j_jdk17-lettuce-8.21.0.jar shows why: Bucket4jLettuce.casBasedBuilder(..) returns a builder whose methods are expirationAfterWrite(..), clientClock(..), requestTimeout(..). The older LettuceBasedProxyManager.builderFor(..) entry point is still in the jar and has the with... names. The Lettuce factory also takes a StatefulRedisConnection<K, byte[]>: the values are binary, so the value codec must be ByteArrayCodec.
Now two complete copies of the application, each with its own port and its own Lettuce connection, against one Redis. Requests alternate between them for an API key allowed 5 per minute (output/05-two-instances.txt):
== free-key-123 allows 5 per minute; requests alternate between instance A and B
sent to A -> 200  remaining=4  (served by A)
sent to B -> 200  remaining=3  (served by B)
sent to A -> 200  remaining=2  (served by A)
sent to B -> 200  remaining=1  (served by B)
sent to A -> 200  remaining=0  (served by A)
sent to B -> 429  remaining=0  (served by -)
sent to A -> 429  remaining=0  (served by -)
allowed in total: 5 (not 10)
Five allowed, the sixth refused by B because A had already spent three of the five tokens. The same test with 200 concurrent requests, half to each instance, for a key allowed 20 per minute:
== 200 concurrent requests for pro-key-456 (20 per minute), half to A and half to B
200 sent: 20 allowed, 180 rejected with 429
Exactly 20, not 19 and not 21, because the read-compute-write is conditional. The tests above run both instances in one JVM; to check I was not fooling myself, scripts/two-jvms.sh starts two real JVMs on ports 8081 and 8082, one Redis, and drives them with curl (output/08-two-jvms.txt):
== two JVMs (A on 8081, B on 8082), one redis-server on 6411; free-key-123 allows 5 per minute
request 1 to :8081 -> 200
request 2 to :8082 -> 200
request 3 to :8081 -> 200
request 4 to :8082 -> 200
request 5 to :8081 -> 200
request 6 to :8082 -> 429

$ curl -si -H 'X-API-Key: free-key-123' http://localhost:8081/api/hello
HTTP/1.1 429 
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 60
X-RateLimit-Scope: api-key
Retry-After: 12
Content-Type: application/problem+json

{"type":"about:blank","title":"Too Many Requests","status":429,"detail":"Rate limit exceeded for scope 'api-key'. Retry in 12 second(s).","retryAfterSeconds":12}


$ redis-cli -p 6411 --scan
rl:global
rl:key:ea39d5800282edc4
And what is in Redis afterwards is just a handful of short-lived keys. A bucket is a 116-byte string that expires once it has been full for ten seconds, so idle callers cost no memory (output/03-api-keys.txt):
$ redis-cli --scan | sort
rl:global
rl:ip:127.0.0.1
rl:key:ea39d5800282edc4
rl:user:alice
$ redis-cli TYPE rl:key:ea39d5800282edc4
string
$ redis-cli STRLEN rl:key:ea39d5800282edc4
116
$ redis-cli PTTL rl:user:alice  (a bucket expires once it has been full for 10 s)
positive and at most 70000: true
Going deeper: what compare-and-swap costs, and what else you could use Each check is at least one round trip to Redis, plus a retry when two instances race for the same key. I did not benchmark the cost in this module, so I will not put a number on it; measure it in your environment with your latency to Redis before deciding that every request should pay it. If you cannot afford that, Bucket4j has an optimization layer (RemoteBucketBuilder.withOptimization(..) exists in the 8.21.0 API) that lets an instance take tokens in local batches at the price of an approximate limit. I looked at the signature with javap but did not run it. Lettuce is not the only Redis client Bucket4j supports: the Central listing has sibling artifacts for Jedis (bucket4j_jdk17-jedis) and Redisson (bucket4j_jdk17-redisson), and for Hazelcast, Infinispan and relational databases such as PostgreSQL and MySQL. Because the filter talks only to ProxyManager, swapping the store means replacing one bean.

Per user, per API key, and global: three limits in one filter

The three limits answer different questions. A per-user limit is fairness: one customer cannot starve the others. A per-API-key limit is a product decision: the free plan gets 5 requests a minute and the pro plan 20. A global limit is self-defence: whatever the callers do, the service as a whole accepts at most so many. They are three different keys into the same mechanism.
LimitAnswersBucket key in RedisSize comes from429 scope
per useris one customer starving the rest?rl:user:<name>ratelimit.useruser
per API keywhat did this customer buy?rl:key:<16 hex of SHA-256>ratelimit.plans via ratelimit.api-keysapi-key
anonymousno identity at allrl:ip:<address>ratelimit.anonymousanonymous
globalcan the service survive everyone together?rl:globalratelimit.globalglobal
The identity decision is a small method in the filter: API key first, then user, then the client address for callers with neither. Notice what it does with an unknown key.
    private Caller identify(HttpServletRequest request) {
        String apiKey = request.getHeader("X-API-Key");
        if (apiKey != null) {
            String plan = props.apiKeys().get(apiKey);
            if (plan == null) {
                return null;   // unknown key: 401, and no bucket is created for it
            }
            return new Caller("api-key", "rl:key:" + sha256Prefix(apiKey), props.plans().get(plan));
        }
        String user = request.getUserPrincipal() != null
                ? request.getUserPrincipal().getName() : request.getHeader("X-User-Id");
        if (user != null && !user.isBlank()) {
            return new Caller("user", "rl:user:" + user, props.user());
        }
        return new Caller("anonymous", "rl:ip:" + request.getRemoteAddr(), props.anonymous());
    }
Two details matter. An unknown API key gets 401 and no bucket is created, otherwise anyone could fill Redis with keys by sending random header values. And the key in Redis is the first 16 hex digits of a SHA-256 of the API key, so a secret never appears in the key space where redis-cli --scan or a monitoring tool could show it. The limits themselves are configuration (application.yml):
ratelimit:
  fail-open: true
  config-version: 1
  user:
    capacity: 10
    period: 1m
  anonymous:
    capacity: 5
    period: 1m
  global:
    capacity: 1000
    period: 1m
  plans:
    free:
      capacity: 5
      period: 1m
    pro:
      capacity: 20
      period: 1m
  api-keys:
    free-key-123: free
    pro-key-456: pro
  costs:
    "[/api/report]": 5
Free and pro plans, with 25 requests each (output/03-api-keys.txt), then the expensive endpoint that costs five tokens per call:
== free plan is 5 per minute, pro plan is 20 per minute
25 requests each: free-key-123 allowed 5, pro-key-456 allowed 20
== /api/report costs 5 tokens, /api/hello costs 1
GET /api/hello  X-API-Key: pro-key-456  -> 200  limit=20 remaining=19 retry-after=null
GET /api/report X-API-Key: pro-key-456  -> 200  limit=20 remaining=14 retry-after=null
GET /api/report X-API-Key: pro-key-456  -> 200  limit=20 remaining=9 retry-after=null
GET /api/report X-API-Key: pro-key-456  -> 200  limit=20 remaining=4 retry-after=null
GET /api/report X-API-Key: pro-key-456  -> 429  limit=20 remaining=4 retry-after=3
-- 20 tokens = 1 hello + 3 reports (16), 4 left; a 4th report needs 5, so it waits for the refill
Twenty tokens buy one /api/hello and three /api/report calls (16 tokens); the fourth report needs five and only four are left, so it is refused with Retry-After: 3 — three seconds is one token at 20 per minute.
Three kinds of limit, two buckets per requestwho is calling?X-API-Key -> api-keyX-User-Id -> userneither -> anonymouscaller bucketrl:key:<hash>rl:user:alicerl:ip:127.0.0.1global bucketrl:globaleveryone,every instance200429, scope=userno global token spent429, scope=globalthe caller token is already spentChecking the caller first means a caller who is over their own limit never costs the global bucket anything;the price is the second 429 above (transcript 04 shows u5 left with 9 of 10 tokens).
The caller bucket is checked first, then the global bucket. Two different 429s are possible, and the second one has a side effect.
The global bucket is shrunk to 12 per minute in the next test so that four users, each far below their own limit of 10, can empty it together. Then a fifth user who has never called before is refused (output/04-global.txt):
== u5 has never called before, so their own bucket is full
GET /api/hello  X-User-Id: u5  -> 429  limit=12 remaining=0 retry-after=5 scope=global
-- u5 was rejected by the global bucket, but their own bucket already paid: 9 of 10 tokens left
== five seconds later (Retry-After), u5 tries again
GET /api/hello  X-User-Id: u5  -> 200  limit=10 remaining=8 retry-after=null scope=user
The order of the two checks is a choice with a visible cost. The caller’s bucket is consulted first, so a caller already over their own limit never spends a global token. The price is the line above: u5 was refused by the global bucket but had already paid a token from their own, and has 9 of 10 left instead of 10. Reversing the order protects the caller and instead lets a caller over their own limit drain the global bucket with requests that were going to be refused anyway. Bucket4j has no transaction across two keys, so one of the two imperfections is unavoidable; I chose the one that stops a single abusive caller from taking the whole service down.
The X-User-Id header is a stand-in, not authentication. The demo needs no login, so it reads a header; anyone can send any value, and a client that rotates the header gets a fresh bucket each time. In a real application the filter takes the name from request.getUserPrincipal() (the code above already prefers it when present) and ignores the header. The anonymous bucket is keyed on request.getRemoteAddr(), which behind a reverse proxy is the proxy’s address; Spring Boot has forwarded-header settings for that, which this module does not exercise.
Going deeper: what to key on, and what the plans table hides Per-IP limits punish everyone behind one corporate NAT and miss attackers with many addresses, so treat the anonymous bucket as a blunt floor rather than a fair limit. For API keys, the plan table here is a map in application.yml; in a real system it is a database row, and you will want to cache the plan lookup because it now sits on the hot path of every request.

Where it goes wrong: three failures you can reproduce

1. You change the limit and nothing changes

A Bucket4j bucket stores its configuration in Redis next to its tokens. The configuration supplier you pass to build(key, supplier) runs only when the key does not exist yet. So raising ratelimit.user.capacity from 10 to 20 and redeploying leaves every existing bucket at 10 — and, worse, the X-RateLimit-Limit header reads the new property, so the response claims 20 while the bucket enforces 10. The test does three deploys in sequence against one Redis (output/06-config-change.txt):
== deploy 1: user limit 10, config-version 1. alice makes 3 requests.
stored capacity in Redis: 10
== deploy 2: user limit raised to 20 in application.yml, config-version still 1
X-RateLimit-Limit header says 20
stored capacity in Redis:   10
X-RateLimit-Remaining:      6
== deploy 3: limit 20 and config-version 2
X-RateLimit-Limit header says 20
stored capacity in Redis:   20
X-RateLimit-Remaining:      11
Deploy 2 is the trap: header 20, stored capacity 10. Deploy 3 bumped ratelimit.config-version, which this module passes to withImplicitConfigurationReplacement(version, TokensInheritanceStrategy.PROPORTIONALLY). The stored bucket is replaced and its tokens are carried over in proportion: alice had 6 of 10 left, so she moved to 12 of 20 (11 after her request). The cost of the fix is a rule for your team: a limit change is a code change plus a version bump. The call is in RateLimiter.java.

2. A clock that disagrees hands out tokens

With the Redis integration the instances do the arithmetic and Redis only stores the result, so refill is computed from each instance’s own clock. Give instance B a clock 30 seconds ahead of A’s and B believes 30 seconds of refill happened that A never saw. In the test A empties carol’s bucket of 10 and is refused; B then lets five more through (at 10 per minute, 30 seconds is five tokens):
== instance B's clock is 30 s ahead of A's (user carol: 10 per minute, one token per 6 s)
A, bucket empty -> 429
B sees 30 s of refill that never happened on A's clock: 5 of 8 allowed
I created the skew with the manual clock; I did not measure how far real servers drift. The lesson is the same: run NTP or chrony on every host that touches a shared bucket, and treat clock skew between instances as part of your effective limit.

3. Redis is down: fail open or fail closed?

Every request now depends on Redis. When it is unreachable you must decide between letting traffic through unlimited (fail open) and refusing it (fail closed). The module exposes that as ratelimit.fail-open, and the test kills a real redis-server under a running application, once per setting (output/07-redis-outage.txt):
== ratelimit.fail-open=true
Redis up:   200  X-RateLimit-Remaining=9
Redis down: 200  X-RateLimit-Status=unavailable  X-RateLimit-Remaining=-
the request waited for the 500 ms command timeout: true
exception the filter caught: io.lettuce.core.RedisCommandTimeoutException
== ratelimit.fail-open=false
Redis up:   200  X-RateLimit-Remaining=9
Redis down: 503  X-RateLimit-Status=-  X-RateLimit-Remaining=-
the request waited for the 500 ms command timeout: true
The request does not fail instantly: it waits for the 500 ms command timeout set in RedisRateLimitConfig.java and then fails with io.lettuce.core.RedisCommandTimeoutException. Fail open protects availability and abandons the limit while Redis is down; fail closed protects the backend and turns a Redis outage into an API outage. For a public API behind a gateway that already does coarse limiting, fail open is usually the less damaging choice, but that is a judgement about your system, not a fact about Bucket4j.
Going deeper: other ways it can bite, not run here
  • Hot keys. One global bucket means every request in the fleet contends for one Redis key. I ran 200 concurrent requests, not a load test, so I have no throughput figure for it.
  • Rejected requests still cost something. A 429 is cheap but not free: it still costs a Redis round trip. A limiter does not protect Redis from the flood it is limiting.
  • Redis Cluster. Bucket4jLettuce.casBasedBuilder has overloads for RedisClusterClient and cluster connections in the 8.21.0 jar. I tested only a standalone server.

Testing rate limits without sleeping

A rate limit is time-dependent, which is the usual recipe for slow, flaky tests. Two decisions removed both problems. First, the clock is a bean: Bucket4j takes its time from a TimeMeter, so the test supplies one it can move.
public class ManualClock implements TimeMeter {

    private final AtomicLong nanos = new AtomicLong(System.currentTimeMillis() * 1_000_000L);

    public ManualClock advance(Duration d) {
        nanos.addAndGet(d.toNanos());
        return this;
    }

    @Override
    public long currentTimeNanos() {
        return nanos.get();
    }

    @Override
    public boolean isWallClockBased() {
        return true;
    }
}
In production the proxy manager gets TimeMeter.SYSTEM_MILLISECONDS; in a test, a @TestConfiguration provides a ManualClock bean and RedisRateLimitConfig picks it up through an ObjectProvider. Second, Redis is real: a throwaway redis-server on a free port, flushed before each test (RedisServer.java). A mock would have verified my idea of what Redis does; a real server verified what Bucket4j and Lettuce do. With both in place the test for “honour Retry-After” reads like the sentence (PerUserLimitTest.java, a MockMvc test against the full application):
        long wait = Long.parseLong(rejected.getResponse().getHeader("Retry-After"));
        clock.advance(Duration.ofSeconds(wait - 1));
        t.line("-- after %d s: still too early", wait - 1);
        assertThat(call("alice").getResponse().getStatus()).isEqualTo(429);
        clock.advance(Duration.ofSeconds(1));
The suite has three layers: in-memory buckets with no Spring (3 tests), MockMvc against the full application and a real Redis (9 tests), and whole applications started on real ports (6 tests, including the two-instance, config-change and outage cases). The last line of output/00-test-summary.txt (timings drift from run to run, so only the totals matter):
Tests run: 18, Failures: 0, Errors: 0, Skipped: 0
Boot 4 test-slice packaging. @AutoConfigureMockMvc now lives in org.springframework.boot.webmvc.test.autoconfigure, in the artifact spring-boot-webmvc-test (pulled in by spring-boot-starter-webmvc-test); the 4.1.2 spring-boot-test-autoconfigure jar no longer contains AutoConfigureMockMvc at all. I confirmed both by listing the jars; RedisBackedTest imports it from the new package.
Going deeper: testing the filter in isolation, and Testcontainers If you have Docker, replace RedisServer with a GenericContainer<>("redis:7") and read the mapped port in the same @DynamicPropertySource; nothing else in the module depends on how Redis was started. I could not run that variant here and did not try to. For a pure unit test of the decision logic you can skip Redis too: Bucket4j in-memory buckets implement the same interface, which is how the first three tests in the suite work.
  • RedisBackedTest.java — the shared base class: full application, MockMvc, real Redis, manual clock
  • README.md of the module — the transcript index

Should you rate limit in the application at all?

Often the answer is: not only there. Coarse protection (a few hundred requests a second per address, connection limits, bot filtering) belongs in the layer in front of the application — a gateway, a reverse proxy or a CDN — where a rejected request costs the application nothing. The application is the right place for limits that need business knowledge: this API key is on the free plan, this tenant bought 20 per minute, this endpoint is five times as expensive. If you only need the coarse kind, do not add a Redis dependency to every request path for it.

If you do want it in the application, note what you have bought: a new hard dependency on Redis (with a decision to make about its failure), a rule that limit changes need a version bump, and a requirement for synchronised clocks. There is also a Spring Boot starter for Bucket4j maintained separately (com.giffing.bucket4j.spring.boot.starter:bucket4j-spring-boot-starter, 0.14.0 as of its last Central update on 28 February 2026) that drives the same library from properties; I did not test it with Boot 4, so I cannot say whether it works there. This module uses Bucket4j directly because the whole filter fits on a screen or two and every behaviour in it is visible.
For the neighbouring problems, see the posts on Resilience4j circuit breakers (protecting your calls to someone else) and on Spring Framework 7’s @ConcurrencyLimit (capping concurrent calls inside one JVM, which is a different thing from a rate over time).

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.