Bucket4j: Spring Cloud Gateway
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
A gateway is the first place a request meets a limit, and the cheapest place to refuse it: the request never reaches a service, a
thread pool or a database. Spring Cloud Gateway ships a rate limiter built on Bucket4j for both of its stacks. This page covers
the Web MVC one (Bucket4jFilterFunctions.rateLimit), the WebFlux one (the Bucket4j rate limiter of the RequestRateLimiter
filter), what they need from the application (an AsyncProxyManager bean and a Bucket4j version that nobody manages for you), how
they differ from the Redis RequestRateLimiter that has existed for years, and how an edge limit and an in-service limit divide the
work. The Bucket4j concepts it relies on are in
token bucket and bandwidths and
distributed rate limiting.
It was written against Bucket4j 8.21.0, Spring Boot 4.1.1, Spring Framework 7.0.9, Spring Cloud 2025.1.3 (Spring Cloud Gateway
5.0.3) and Java 21, and verified on 4 October 2026. Every example on it was run: the Web MVC gateway against a stub
bookshelf-service in the same JVM, two gateway instances sharing one Redis 7 in a Testcontainers container, and the WebFlux
gateway with the YAML shown here, so the status codes and headers quoted are the observed ones. The numbers are deliberately
small (three requests per minute) so that a test can exhaust them; the filter does not care about the size.
Which limiter is on which gateway
Spring Cloud Gateway has two server stacks, and each has its own rate-limiting entry point. They share the idea (a key resolver decides who, a limiter decides whether) and little else.
| Gateway stack | Entry point | Algorithm and store | Configured in |
|---|---|---|---|
Web MVC ( |
|
Bucket4j token bucket in whatever backend the |
Java DSL only; no property binding |
WebFlux ( |
|
Bucket4j token bucket in whatever backend the |
YAML, properties or the Java DSL |
WebFlux |
|
A token bucket evaluated by a Lua script in Redis |
YAML, properties or the Java DSL |
The Web MVC filter and the WebFlux limiter both consume a Bucket4j AsyncProxyManager, which is why the backend is a free choice:
a local Caffeine cache for one gateway instance and for tests, Redis through Lettuce when several gateway replicas must share a
limit, or any other backend from the backends page that supports the asynchronous
API. The community Spring Boot starter described on
the starter page no longer carries a gateway filter (it was removed
in 0.20.0-RC1; 0.14.0 still has it), so on a gateway the native filter below is the supported route.
Dependencies and version alignment
Spring Cloud Gateway uses Bucket4j but does not bring it. In both gateway modules bucket4j_jdk17-core is declared as an
<optional> dependency, which Maven does not pass on to the application, so the application declares Bucket4j itself, together
with the module of the backend it picks. Nothing manages that version for you either: neither spring-boot-dependencies
4.1.1, nor spring-cloud-dependencies 2025.1.3, nor spring-cloud-gateway-dependencies 5.0.3 mentions Bucket4j. Gateway 5.0.3’s
own root pom has <bucket4j.version>8.15.0</bucket4j.version> and manages bucket4j_jdk17-core and -caffeine there, so
"Spring Cloud Gateway manages Bucket4j 8.15.0" is true of the gateway’s own build and of nothing a consumer imports. Importing
bucket4j_jdk17-bom 8.21.0 is what decides the version; the dependency tree of the application below showed
bucket4j_jdk17-core:8.21.0, bucket4j_jdk17-caffeine:8.21.0 and Caffeine 3.2.4 (the version Spring Boot manages).
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-bom</artifactId>
<version>8.21.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
</dependency>
<!-- the backend of the AsyncProxyManager: Caffeine here, Lettuce further down -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-caffeine</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>
Source: Bucket4j reference: Adding the Bucket4j dependency and the
Gateway Web MVC pom at 5.0.3
(Bucket4j <optional>true</optional>). The WebFlux gateway needs the same Bucket4j dependencies with
spring-cloud-starter-gateway-server-webflux. The bucket4j_jdk17-bom first appeared in 8.21.0; on an older Bucket4j declare the
versions explicitly.
The Gateway reference shows a Caffeine example that calls new CaffeineProxyManager<>(builder, Duration.ofMinutes(1)). Both
CaffeineProxyManager constructors are @Deprecated in 8.21.0 (the source says to use Bucket4jCaffeine#builderFor(Caffeine)),
the same page links Bucket4j 8.7.0, and the WebFlux section spells the type AsyncProxyMananger. The examples here use the builder.
The AsyncProxyManager bean
Both limiters get their buckets from a Spring bean of type io.github.bucket4j.distributed.proxy.AsyncProxyManager, with String
keys on WebFlux. A ProxyManager is turned into one with asAsync(). The simplest backend is Caffeine, a local cache that lives in
the gateway JVM:
@Configuration
class CaffeineProxyConfig {
@Bean
AsyncProxyManager<String> asyncProxyManager() {
return Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder().maximumSize(10_000))
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.build()
.asAsync();
}
}
Source: Bucket4j reference: Asynchronous API and
Bucket4jCaffeine at 8.21.0.
maximumSize bounds the memory (one entry per key), and expirationAfterWrite removes the buckets of clients that went away;
without both, a key space an attacker controls grows without limit. A Caffeine bucket lives in one JVM: with three gateway
replicas a limit of 100 per minute becomes up to 300, exactly as on
the distributed page. For a limit that must hold across
replicas, back the same bean with Redis through Lettuce:
@Configuration
class RedisProxyConfig {
@Bean(destroyMethod = "shutdown")
RedisClient redisClient(@Value("${redis.host}") String host, @Value("${redis.port}") int port) {
return RedisClient.create(RedisURI.create(host, port));
}
@Bean
AsyncProxyManager<String> asyncProxyManager(RedisClient redisClient) {
return Bucket4jLettuce.casBasedBuilder(redisClient)
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.build()
.withMapper((String key) -> key.getBytes(StandardCharsets.UTF_8))
.asAsync();
}
}
Source: Bucket4j reference: bucket4j-lettuce and
Asynchronous API. It needs bucket4j_jdk17-redis-common and
bucket4j_jdk17-lettuce and Lettuce, which Spring Boot manages. Spring Boot creates no RedisClient bean (it exposes a
LettuceConnectionFactory), so the configuration declares one. Run: two complete gateway instances on ports 18091 and
18092 pointed at one Redis 7 container, with a limit of three requests per minute per API key, were called alternately with the same key.
18091 -> 200 remaining=2
18092 -> 200 remaining=1
18091 -> 200 remaining=0
18092 -> 429 remaining=0
18091 -> 429 remaining=0
other key on 18092 -> 200
The fourth request, sent to the other replica, was refused: the count is in Redis, not in the gateway. The same test with Caffeine
would have admitted three requests on each instance. The behaviour of the CAS protocol and of the client-side settings
(requestTimeout, maxRetries, clientClock) is on the distributed page; on a gateway they matter more than elsewhere, because
the call is on the path of every request, as the timeout below shows.
When no AsyncProxyManager bean exists, the two stacks fail differently. On WebFlux the Bucket4j limiter bean is simply not created
(it is conditional on the class and on the bean). On Web MVC the filter looks the bean up on every request, so the application
starts and then fails: a gateway without the bean answered 500 to every request, with a key and without one, with
NoSuchBeanDefinitionException in the log, because the lookup comes before the key check.
Web MVC: Bucket4jFilterFunctions.rateLimit
The Web MVC filter is a HandlerFilterFunction<ServerResponse, ServerResponse> that is attached to a route with .filter(…).
It has two overloads, both static in org.springframework.cloud.gateway.server.mvc.filter.Bucket4jFilterFunctions:
static HandlerFilterFunction<ServerResponse, ServerResponse> rateLimit(
long capacity, Duration period, Function<ServerRequest, String> keyResolver)
static HandlerFilterFunction<ServerResponse, ServerResponse> rateLimit(
Consumer<RateLimitConfig> configConsumer)
Source: Bucket4jFilterFunctions source
and Spring Cloud Gateway: Web MVC rate limiter.
The first overload is shorthand for the second with capacity, period and keyResolver set. Everything else is a field of
RateLimitConfig:
| Field (setter) | Type | Default | Meaning |
|---|---|---|---|
|
|
|
Size of the bucket, and with the default builder also the tokens added per |
|
|
none |
The time over which |
|
|
none |
Who is being limited. With no resolver the key is empty and every request is refused with 403 |
|
|
|
Status of a rejected request |
|
|
|
Tokens one request costs |
|
|
|
Header that carries the remaining tokens, on allowed and on rejected responses |
|
|
none, the filter waits indefinitely |
How long to wait for the bucket store to answer |
|
|
One greedy bandwidth: |
Replaces the default bucket configuration (see below) |
The fields come from the 5.0.3 source; the reference page documents capacity, period, keyResolver, statusCode, tokens,
headerName and timeout and says nothing about configurationBuilder. The default configuration builder is
Bandwidth.builder().capacity(c).refillGreedy(c, period), so the refill is greedy unless you replace it.
A per-client limit for the Bookshelf API
In the Bookshelf scenario every client sends an API key, and the gateway forwards /gw/books to bookshelf-service. The route
below uses the consumer form, resolves the key from the X-API-Key header and allows three requests per minute per key:
@Configuration
class GatewayRoutes {
/** Who is calling: the API key header. A missing header gives no key, and the filter answers 403. */
static final Function<ServerRequest, String> API_KEY =
request -> request.headers().firstHeader("X-API-Key");
@Value("${bookshelf.service.uri}")
String target;
@Bean
RouterFunction<ServerResponse> books() {
return route("bookshelf_books")
.GET("/gw/books", http())
.before(rewritePath("/gw/books", "/api/books"))
.before(uri(target))
.filter(rateLimit(c -> c.setCapacity(3)
.setPeriod(Duration.ofMinutes(1))
.setKeyResolver(API_KEY)))
.build();
}
/** The same limit with the first overload. */
@Bean
RouterFunction<ServerResponse> shortForm() {
return route("bookshelf_short")
.GET("/gw/short", http())
.before(rewritePath("/gw/short", "/api/books"))
.before(uri(target))
.filter(rateLimit(2, Duration.ofMinutes(1), API_KEY))
.build();
}
}
Source: Spring Cloud Gateway: Web MVC rate limiter
and the Bucket4jFilterFunctions source. The route path is a distinct gateway path (/gw/) rewritten to the service path
(/api/). When the gateway and the stub service share one server, routing /api/** to itself loops through the filter twice, which
is how this was discovered; with a real service on another host the paths can be the same.
Observed for the same key, with books (three tokens) and shortForm (two tokens):
GET /gw/books X-API-Key: o2 200 X-RateLimit-Remaining: 2
GET /gw/books X-API-Key: o2 200 X-RateLimit-Remaining: 1
GET /gw/books X-API-Key: o2 200 X-RateLimit-Remaining: 0
GET /gw/books X-API-Key: o2 429 X-RateLimit-Remaining: 0 (no Retry-After)
GET /gw/short X-API-Key: o1 200 remaining 1, 200 remaining 0, 429 remaining 0
An allowed response carries the remaining tokens in the configured header; a rejected response carries the same header (0) and
nothing else: no Retry-After, no body, no RateLimit header. Clients that should back off need those added by a later
filter, which is a good place for the Retry-After convention discussed on
the distributed page. Another key was unaffected by the first key’s
exhaustion, which is the point of resolving a key per client.
Source: Spring Cloud Gateway: Web MVC rate limiter and the
Bucket4jFilterFunctions source.
The key and the 403
The key resolver returns a String. When the resolver is not set, or returns null, an empty string or blank text, the filter
answers 403 Forbidden without touching any bucket, and the source has a TODO for making that status configurable, so it is not.
Run: no X-API-Key header and a header containing a single space both returned 403, and the response had no rate-limit header.
The resolver decides everything the limit means, so what it reads matters as much as the capacity. A header the client writes
is not an identity: a client that sends a different random value on every request gets a fresh full bucket every time. The Gateway
reference example uses request.servletRequest().getUserPrincipal().getName(), the authenticated principal; use that, or the client
id of a validated token, for per-client limits, and use the client address (mind the proxy headers, covered on
the production checklist) for an
anonymous limit. Put a prefix such as gw: in front of the key when the store is shared with other data, and do not use a raw
secret such as an API key as the key if the store can be read by others: derive an identifier from it.
The Bucket4j key is exactly what the resolver returns. It does not include the route. Two routes that use the same resolver share
one bucket per client, and the configuration of whichever route touched the key first wins. Run: /gw/a allows two requests, /gw/b
allows 100. After two calls to /gw/a and a rejected third, /gw/b with the same key was rejected too (429, remaining 0), while a
fresh key on /gw/b was allowed with 99 remaining. If a client should have an independent budget per route, include the route in
the key ("books:" + apiKey). The Redis RequestRateLimiter builds its Redis keys from the route id and the resolver key
and therefore behaves the other way round.
tokens, headerName and statusCode
A request that costs more than one token models an expensive endpoint, such as the Bookshelf search that costs five. headerName
renames the response header and statusCode replaces 429:
// 10 tokens, search costs 5, custom header
.filter(rateLimit(c -> c.setCapacity(10)
.setPeriod(Duration.ofMinutes(1))
.setTokens(5)
.setHeaderName("X-Quota-Remaining")
.setKeyResolver(API_KEY)))
// a different rejection status
.filter(rateLimit(c -> c.setCapacity(1)
.setPeriod(Duration.ofMinutes(1))
.setStatusCode(HttpStatusCode.valueOf(503))
.setKeyResolver(API_KEY)))
Source: Spring Cloud Gateway: Web MVC rate limiter.
Run: the search route returned 200 X-Quota-Remaining: 5, 200 X-Quota-Remaining: 0, then 429 X-Quota-Remaining: 0, and the
default X-RateLimit-Remaining header was absent. The one-token route with statusCode 503 returned 200 and then 503. A 429 remains
the right status for a client that is over its limit; a different one is for an edge that wants to distinguish limiting from other
rejections.
configurationBuilder: any bucket configuration
The default bucket has one greedy bandwidth. configurationBuilder takes the RateLimitConfig and returns a complete
BucketConfiguration, so everything from
the bandwidths page applies: several bandwidths, intervally
refill, initial tokens, bandwidth ids. Two examples:
// a per-minute budget plus a burst guard: at most 2 requests per 10 seconds
.filter(rateLimit(c -> c.setKeyResolver(API_KEY)
.setConfigurationBuilder(config -> BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofMinutes(1)))
.addLimit(limit -> limit.capacity(2).refillGreedy(2, Duration.ofSeconds(10)))
.build())))
// nothing comes back until the whole period has elapsed, then everything does
.filter(rateLimit(c -> c.setKeyResolver(API_KEY)
.setConfigurationBuilder(config -> BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(2).refillIntervally(2, Duration.ofSeconds(2)))
.build())))
Source: Bucket4j reference: Bandwidth and
Refill types. With a custom builder capacity and period are not used unless your
lambda reads them. Run: with the two-bandwidth builder the first two requests returned 200 with remaining 1 and 0, and the third
returned 429, because the burst bandwidth was exhausted while the minute budget still had 98 tokens; the reported remaining is the
smallest of the bandwidths.
Greedy and intervally refill, same capacity 2 per 2 seconds, both emptied at the start:
after 1.2 s: greedy route 200 (remaining 0) intervally route 429
after 2.2 s: intervally route 200 (remaining 1)
The greedy bucket gave back one token after about a second; the intervally bucket gave nothing back until the period was over and then refilled completely (two tokens, one consumed by the request). Greedy is smoother; intervally permits a burst at every boundary, as shown on the token bucket page.
Source: Bucket4j reference: refill types.
timeout: do not wait for a dead store
The Web MVC filter blocks the request thread on the future that Bucket4j returns: bucketFuture.get() with no timeout, or
get(timeout) when timeout is set. The default is to wait forever, which with a remote store means a hung store hangs requests.
Run: against Redis, a route without timeout and a route with setTimeout(Duration.ofMillis(300)) were called while the Redis
container was paused (frozen, not stopped):
route with timeout 300 ms: 500 after 347 ms
route without timeout: no answer within 3 s (the client gave up)
Set a timeout, and decide what the gateway should do about the failure. The timeout is a TimeoutException inside the filter,
which Spring turns into a 500; there is no built-in fail-open. If an edge limit must not take the API down when the limit store
fails, wrap the filter (or the call through the ProxyManager) with your own policy, as the fail-open and fail-closed discussion
on fallbacks and composition does for Resilience4j, and prefer a
short requestTimeout on the Redis backend over a long wait.
Source: the
Bucket4jFilterFunctions source
and Bucket4j reference: request timeout.
What the filter does, in order
-
It looks up the
AsyncProxyManagerbean from the application context (a missing bean is a 500, even for a request with no key). -
It calls the key resolver; a blank key returns 403.
-
It builds a bucket proxy for the key with a configuration supplier that runs the
configurationBuilderon the application task executor, and only when the bucket does not yet exist in the store. -
It calls
tryConsumeAndReturnRemaining(tokens)and waits for the answer. -
When the request is allowed, it calls the rest of the route and adds the remaining-tokens header to the response; otherwise it answers with
statusCodeand the header and does not call the service.
The supplier in step 3 matters in a shared store. A bucket keeps the configuration it was created with, so changing capacity in the
route and redeploying does not change buckets that already exist in Redis; they change when they expire, or when you replace the
configuration explicitly (see implicit replacement on the distributed page). The filter itself does not do this.
WebFlux: the Bucket4j rate limiter of RequestRateLimiter
The WebFlux gateway has had a RequestRateLimiter filter for years, with RedisRateLimiter as its only built-in limiter. Since
Spring Cloud 2025.0 it also ships a Bucket4j RateLimiter. Declaring an AsyncProxyManager<String> bean is enough to create it:
the Bucket4jRateLimiter bean is conditional on the AsyncProxyManager class and on the bean. The limiter is configured with
bucket4j-rate-limiter.* arguments of the filter, and the KeyResolver is a bean referenced by SpEL:
spring:
cloud:
gateway:
server:
webflux:
routes:
# 3 per minute per API key, greedy refill (the default)
- id: books
uri: ${bookshelf.service.uri}
predicates:
- Path=/gw/books
filters:
- RewritePath=/gw/books, /api/books
- name: RequestRateLimiter
args:
key-resolver: "#{@apiKeyResolver}"
bucket4j-rate-limiter.capacity: 3
bucket4j-rate-limiter.refillPeriod: 1m
# capacity 4, 2 tokens back every 4 seconds, all at once at the end of the period
- id: intervally
uri: ${bookshelf.service.uri}
predicates:
- Path=/gw/intervally
filters:
- RewritePath=/gw/intervally, /api/books
- name: RequestRateLimiter
args:
key-resolver: "#{@apiKeyResolver}"
bucket4j-rate-limiter.capacity: 4
bucket4j-rate-limiter.refillTokens: 2
bucket4j-rate-limiter.refillPeriod: 4s
bucket4j-rate-limiter.refillStyle: INTERVALLY
# the search endpoint costs 5 tokens and reports in its own header
- id: search
uri: ${bookshelf.service.uri}
predicates:
- Path=/gw/search
filters:
- RewritePath=/gw/search, /api/books
- name: RequestRateLimiter
args:
key-resolver: "#{@apiKeyResolver}"
bucket4j-rate-limiter.capacity: 10
bucket4j-rate-limiter.refillPeriod: 1m
bucket4j-rate-limiter.requestedTokens: 5
bucket4j-rate-limiter.headerName: X-Quota-Remaining
# refills aligned to a fixed instant
- id: aligned
uri: ${bookshelf.service.uri}
predicates:
- Path=/gw/aligned
filters:
- RewritePath=/gw/aligned, /api/books
- name: RequestRateLimiter
args:
key-resolver: "#{@apiKeyResolver}"
bucket4j-rate-limiter.capacity: 2
bucket4j-rate-limiter.refillPeriod: 1h
bucket4j-rate-limiter.refillStyle: INTERVALLY_ALIGNED
bucket4j-rate-limiter.timeOfFirstRefill: "2026-01-01T00:00:00Z"
@Bean
KeyResolver apiKeyResolver() {
return exchange -> Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("X-API-Key"));
}
@Bean
AsyncProxyManager<String> asyncProxyManager() { // same Caffeine bean as above
return Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder().maximumSize(10_000)).build().asAsync();
}
Source: Spring Cloud Gateway: RequestRateLimiter GatewayFilter Factory
(section Bucket4j RateLimiter) and the Bucket4jRateLimiter source at 5.0.3. The reference page’s own example sets
capacity: 20, refillTokens: 10 and refillPeriod: 1s, which is a bucket of 20 with a refill of 10 per second.
| Argument | Type | Default | Meaning |
|---|---|---|---|
|
|
|
Tokens the bucket holds, the largest burst. The reference text says "in a single second", which is only true for a one-second period |
|
|
none, required |
Period in which |
|
|
|
Tokens added per |
|
|
|
Tokens one request costs |
|
|
|
How refill happens, as on the bandwidths page |
|
|
none |
Only for |
|
|
|
Header that carries the remaining tokens |
Observed, with the YAML above: /gw/books for one key returned 200 remaining=2, 200 remaining=1, 200 remaining=0, 429 remaining=0,
and the response headers were only content-length and x-ratelimit-remaining. /gw/search returned 200 X-Quota-Remaining: 5,
200 X-Quota-Remaining: 0 and then 429. On /gw/intervally (capacity 4, two tokens per four seconds) four requests passed,
the fifth was refused, a request after 2.0 s was still refused, and after 4.2 s two requests passed (remaining 1, then 0) and the third
was refused. The aligned route admitted two requests and refused the third. The rejection status is the statusCode argument of
RequestRateLimiter itself, 429 by default; status-code: SERVICE_UNAVAILABLE returned 503.
Empty keys: the corrected deny-empty-key prefix
When the KeyResolver returns nothing, RequestRateLimiter denies the request with FORBIDDEN by default. The two global
settings are properties of the filter factory. The 5.0.x reference page still shows the old prefix; the code binds
spring.cloud.gateway.server.webflux.filter.request-rate-limiter:
spring:
cloud:
gateway:
server:
webflux:
filter:
request-rate-limiter:
deny-empty-key: false # default true
empty-key-status-code: UNAUTHORIZED # default FORBIDDEN, used only while deny-empty-key is true
Source: Spring Cloud Gateway: RequestRateLimiter GatewayFilter Factory
and the RequestRateLimiterGatewayFilterFactory source at 5.0.3 (@ConfigurationProperties("spring.cloud.gateway.server.webflux.filter.request-rate-limiter")).
Run, with no X-API-Key header: by default 403; with deny-empty-key=false under the prefix above, 200 and no limiting; with
deny-empty-key=false under the old prefix spring.cloud.gateway.filter.request-rate-limiter, still 403, so the old property is
silently ignored; with empty-key-status-code=UNAUTHORIZED, 401. Both settings can also be given per route as the
deny-empty-key and empty-key-status arguments of the filter. Letting key-less requests through disables the limit for
them, so allow it only on routes that have another protection.
Failure: the Bucket4j limiter fails closed
If the call to Bucket4j fails (a store outage, a timeout, an exception), Bucket4jRateLimiter swallows the error, logs it at DEBUG,
and answers as if the bucket were empty: ConsumptionProbe.rejected(-1, -1, -1), so the request is refused. Run: an
AsyncProxyManager whose bucket returned a failed future made every request answer 429 with X-RateLimit-Remaining: 0. The remaining
count is clamped to zero, so a limit that is working and a store that is down look the same from outside, and the only trace is
a DEBUG line. Monitor the store, and log or count these errors if a silent 429 would be costly. The Redis limiter does the
opposite, as the next section explains.
A shared key across routes
As on Web MVC, the key handed to the AsyncProxyManager is the resolver’s output alone. Run: route-a with capacity 2 and route-b
with capacity 100, one resolver: after two calls and a rejected third on route-a, the same key on route-b was also rejected (429);
a fresh key on route-b was allowed with 99 remaining. Make the key route-specific in the resolver when budgets must be independent.
The Redis RequestRateLimiter, for contrast
RedisRateLimiter is the older limiter of the same filter. It needs spring-boot-starter-data-redis-reactive, and it is configured
with redis-rate-limiter.* arguments:
- id: redis-books
uri: ${bookshelf.service.uri}
predicates:
- Path=/gw/redis
filters:
- RewritePath=/gw/redis, /api/books
- name: RequestRateLimiter
args:
key-resolver: "#{@apiKeyResolver}"
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 3
redis-rate-limiter.requestedTokens: 1
Source: Spring Cloud Gateway: RequestRateLimiter GatewayFilter Factory
(Redis RateLimiter section). Run against Redis 7: 200 remaining=2, 200 remaining=1, 200 remaining=0, then 429 remaining=0
twice, and every response carried X-RateLimit-Replenish-Rate: 1, X-RateLimit-Burst-Capacity: 3 and
X-RateLimit-Requested-Tokens: 1. The Redis keys were request_rate_limiter.{redis-books.k1}.tokens and .timestamp: the route id
is in the key.
| Aspect | Redis RequestRateLimiter (RedisRateLimiter) |
Bucket4j limiter |
|---|---|---|
Arguments |
|
|
Time unit |
One second, fixed. A rate below one per second is expressed through |
Any period |
Refill models |
Continuous only |
Greedy, intervally, intervally aligned, or any |
Stores |
Redis only, evaluated by a Lua script |
Any Bucket4j backend with an async API (Caffeine, Redis, JCache, Hazelcast and others) |
Several bandwidths |
No |
Yes with a custom configuration (Java on Web MVC; the WebFlux arguments describe one bandwidth) |
Key |
|
The resolver’s output only; add the route yourself if you need it |
Response headers |
|
Only the remaining-tokens header ( |
When the store fails |
Logs the error and allows the request, with |
Answers as if the bucket were empty: the request is denied |
Stack |
WebFlux only |
WebFlux and Web MVC |
The Redis row on failure comes from the 5.0.3 source and from the measurement on
Gateway and OpenFeign, which saw X-RateLimit-Remaining: -1 and a 200 against an
unreachable Redis. Whether failing open or closed is right depends on what the limit protects; decide it on purpose, because the two
built-in limiters choose differently.
Both limiters on one gateway
With spring-boot-starter-data-redis-reactive on the classpath and an AsyncProxyManager bean, there are two RateLimiter beans
(redisRateLimiter and bucket4jRateLimiter), and the RequestRateLimiter factory wants exactly one by default. Run: without a
primary bean the application failed at startup with NoUniqueBeanDefinitionException: … expected single matching bean but found 2:
redisRateLimiter,bucket4jRateLimiter. Mark one as primary, and name the other in the routes that want it:
@Bean
@Primary
RedisRateLimiter redisRateLimiter(ReactiveStringRedisTemplate redisTemplate,
@Qualifier(RedisRateLimiter.REDIS_SCRIPT_NAME) RedisScript<List<Long>> redisScript,
ConfigurationService configurationService) {
return new RedisRateLimiter(redisTemplate, redisScript, configurationService);
}
- id: bucket4j-books
uri: ${bookshelf.service.uri}
predicates:
- Path=/gw/bucket4j
filters:
- RewritePath=/gw/bucket4j, /api/books
- name: RequestRateLimiter
args:
key-resolver: "#{@apiKeyResolver}"
rate-limiter: "#{@bucket4jRateLimiter}"
bucket4j-rate-limiter.capacity: 3
bucket4j-rate-limiter.refillPeriod: 1m
Source: Spring Cloud Gateway: RequestRateLimiter GatewayFilter Factory
(custom RateLimiter by SpEL). Run: /gw/redis behaved as above and /gw/bucket4j returned 200 remaining=2, 200 remaining=1,
200 remaining=0, 429 remaining=0 with no X-RateLimit-Burst-Capacity header, each on its own limiter.
Edge limits and in-service limits
An edge limit and an in-service limit answer different questions, and a system usually needs both.
-
The edge limit is about who: it protects the whole system from one client, in one place, before any resource is spent. It knows the client but not the cost of the work behind the route. It needs to be cheap, and it may be approximate.
-
The in-service limit is about what: it protects a resource the service owns (a database, an expensive endpoint, a quota with a third party such as the ISBN provider of the Bookshelf) whoever calls it, including callers that never pass through the gateway: other services, jobs, and anyone who finds the service address.
The figure is the Bookshelf system with limits at both layers. A client is limited per key at the gateway; the service limits its search endpoint per client and holds the shared outbound quota for the ISBN provider; callers that bypass the gateway meet only the second layer. The numbers in the figure are an example; the ones used in the run below were smaller.
Run: the gateway allowed ten requests per minute per key on /gw/search, forwarded to /api/search, and bookshelf-service had its
own local per-client bucket of two per minute on that path, in a HandlerInterceptor:
@Configuration
class ServiceLimitConfig implements WebMvcConfigurer {
private static final BucketConfiguration TWO_PER_MINUTE = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(2).refillGreedy(2, Duration.ofMinutes(1)))
.build();
// bounded and expiring: a caller who invents keys or addresses cannot grow it without limit
private final ProxyManager<String> buckets = Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder().maximumSize(10_000))
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.build();
private final RateLimitProperties properties;
ServiceLimitConfig(RateLimitProperties properties) {
this.properties = properties;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HandlerInterceptor() {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String apiKey = request.getHeader("X-API-Key");
String key = apiKey != null && properties.planOfApiKey(apiKey) != null
? "key:" + ClientIdentifier.fingerprint(apiKey) // issued keys only
: "ip:" + request.getRemoteAddr(); // unknown or missing keys: limited by address
var probe = buckets.getProxy(key, () -> TWO_PER_MINUTE).tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
response.setHeader("X-Service-Remaining", String.valueOf(probe.getRemainingTokens()));
return true;
}
long retryAfterSeconds = Math.max(1, (probe.getNanosToWaitForRefill() + 999_999_999L) / 1_000_000_000L);
response.setStatus(429);
response.setHeader("X-Limited-By", "bookshelf-service");
response.setHeader("Retry-After", String.valueOf(retryAfterSeconds)); // rounded up, never early
return false;
}
}).addPathPatterns("/api/search");
}
}
Source: Bucket4j reference: Quick start examples and
Spring Framework: Interceptors. The buckets
live in a bounded, expiring Caffeine ProxyManager (the same rules as the Caffeine bean above), and only issued keys (layers,
direct and layers-other in the run) get a bucket of their own; ClientIdentifier.fingerprint and RateLimitProperties come
from the manual integration page. The in-service
layers of the Bookshelf (the search interceptor, the shared outbound quota, the ISBN ClientHttpRequestInterceptor) are built on
the manual integration page; this one is the minimum needed to show
the layering.
GET /gw/search X-API-Key: layers 200 gateway remaining 9, service remaining 1
GET /gw/search X-API-Key: layers 200 gateway remaining 8, service remaining 0
GET /gw/search X-API-Key: layers 429 gateway remaining 7, X-Limited-By: bookshelf-service
GET /api/search X-API-Key: direct 200 service remaining 1, 200 service remaining 0, 429
GET /gw/search X-API-Key: layers-other 200
Three things show up in the run. The service refused the third request on its own limit while the gateway still had seven tokens, so
the stricter limit wins for each client, and the X-Limited-By header (a convention of this example, not of Bucket4j) tells the client which layer spoke.
The gateway spent a token on a request that the service then refused, because the gateway cannot know what the service will
do; a gateway limit counts requests it admitted, not requests that succeeded. And the direct call to the service, with no gateway, was
limited by the service alone, which is why the in-service layer is not optional when the service is reachable another way.
Source: Spring Cloud Gateway: Web MVC rate limiter and Bucket4j reference: limiting the rate of access to a REST API.
Where the limit should live is part of the same decision as the shape of the gateway itself, which the architecture discussion in API gateway and BFF covers. A gateway limit by client plan, by IP for anonymous traffic, and a coarse global limit that sheds load before the services notice it, belong at the edge; limits that depend on business cost (five tokens for a search, a daily ISBN quota) belong where the cost is known. Resilience4j at the same gateway is about protecting callers of a failing service, not limiting clients: the circuit breaker and the rate limiter of the gateway are on Gateway and OpenFeign. Beyond a library inside the gateway, a deployment can use a dedicated API manager or gateway product that offers plans, keys, quotas and analytics out of the box; that is a product choice, not a Bucket4j one, and it still leaves the in-service layer in place.
Pitfalls
| Pitfall | What happens | What to do |
|---|---|---|
Bucket4j is not on the classpath, or is an old version |
Gateway declares it |
Import |
No |
Web MVC: every request returns 500 (observed, even with no key). WebFlux: the Bucket4j limiter bean is not created |
Declare the bean; type it |
Following the docs example |
|
|
Caffeine behind several gateway replicas |
Each replica has its own buckets, so the limit multiplies with the replica count |
Redis through Lettuce (or another shared backend) behind the same bean |
Client-controlled key such as a plain header |
Every new value is a fresh full bucket, so the limit can be bypassed |
Key on the authenticated principal or a validated client id; keep a separate limit by address for anonymous requests |
Missing or blank key |
Web MVC always answers 403 (the status is not configurable). WebFlux denies with 403 unless |
Decide what no-key traffic should be; do not turn |
The old |
|
|
Two routes, one resolver |
The key does not contain the route, so the routes share one bucket per client and the first configuration wins (observed on both stacks) |
Put the route in the key when budgets must be independent |
No |
A hung store hangs the request thread (no answer in 3 s against a frozen Redis); with a 300 ms timeout the request returned 500 after 347 ms |
Set |
A store outage on WebFlux |
The limiter denies every request with a 429 whose |
Monitor the store; the Redis limiter fails open instead, so know which one you run |
Changing |
Existing buckets in a shared store keep the configuration they were created with |
Let them expire, or replace the configuration explicitly |
Expecting |
Rejected responses carry only the remaining-tokens header and have no body |
Add a response filter if clients need |
Counting rejected-by-service requests |
The gateway spent a token on a request that the service refused with its own 429 |
Expect it; keep the service limit stricter than, or independent from, the edge limit |
Two |
With both Redis and a Bucket4j |
|
Routing |
The request goes through the filter twice (found while running this) |
A distinct gateway path with |
What changed
-
The Web MVC gateway has a Bucket4j filter (
Bucket4jFilterFunctions.rateLimit) and the WebFlux gateway a Bucket4jRateLimiternext to the Redis one (announced with Spring Cloud 2025.0). Both consume anAsyncProxyManager. -
The Spring Cloud Gateway artifacts were renamed for the two stacks (
spring-cloud-starter-gateway-server-webmvcand-server-webflux), and the WebFlux property prefix gainedserver.webflux: routes are underspring.cloud.gateway.server.webflux.routes, and the request-rate-limiter properties underspring.cloud.gateway.server.webflux.filter.request-rate-limiter, which the reference page fordeny-empty-keystill shows in its old form. -
Bucket4j 8.21.0 (2 October 2026) added
bucket4j_jdk17-bom; Gateway 5.0.3 still builds against 8.15.0 and its documentation links 8.7.0 and a deprecated Caffeine constructor. TheBucket4jCaffeine.builderForform is the supported one. -
The community Spring Boot starter’s gateway filter was removed in 0.20.0-RC1 (0.14.0 still has it); see the starter page.
-
The full list of differences between the documentation and the 8.21.0 sources is on migration and what’s changed, and how the limiters fit with Resilience4j in Resilience4j versus Bucket4j.