Bucket4j: Spring Boot Manual Integration
|
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. |
Bucket4j is a library, not a Spring Boot integration: nothing in Spring Boot’s dependency management, auto-configuration or Actuator knows about it. Wiring it in by hand is about two hundred lines, and those lines decide the things that matter in production: which clients share a bucket, whether the limit runs before or after authentication, what the client sees on a rejection, and what happens when the shared store is down. This page builds that wiring for the Bookshelf service and runs it. The community starter that generates most of it from YAML is on the starter page; the decision between the two is there too.
It was written against Bucket4j 8.21.0, Spring Boot 4.1.x, Spring Framework 7.0.x and Java 21 and verified on 4 October 2026. Every example on this page was compiled and run on Boot 4.1.1 in a scratch project: real HTTP requests against a started servlet container (so the filters really run, which MockMvc does not guarantee), a started WebFlux server, Redis 7 and PostgreSQL 17 in Testcontainers, and a Redis that was paused to simulate an outage. Numbers and headers quoted below were observed in those runs. The Bucket4j concepts used here are on the Bucket API page and the distributed page.
What gets wired
The Bookshelf exposes /books (one token per call) and /books/search (five tokens), identifies callers by authenticated
user, then by API key, then by address, and gives each a plan: free 20 per minute, pro 200, partner 2000. Every replica
shares the buckets through Redis. The pieces are small and each has one job:
| Part | Job |
|---|---|
|
The plans, who is on which plan, what each path costs, what to do when the store fails |
|
The connection to the store, with the client-side settings (expiry, timeout, recovery) |
|
Turns a request into a bucket key and a plan |
|
The only class that consumes tokens; wraps the call in a Resilience4j CircuitBreaker and applies fail-open or fail-closed |
|
Servlet entry point; adds |
|
Turns the exception into a 429 or 503 |
|
The reactive equivalent, on an |
|
Limits non-HTTP entry points |
|
Spends the shared outbound quota before calling the ISBN provider |
The path of a request through the inbound pieces, with Spring Security in front, is:
order -100] S -->|bad credentials| R401[401 Unauthorized] S --> F[RateLimitFilter
order -99] F -->|identify client
consume tokens| P[(ProxyManager
Redis)] F -->|limit exceeded| H[HandlerExceptionResolver
ExceptionHandler] H --> R429[429 + Retry-After
ProblemDetail] F -->|allowed + X-Rate-Limit-Remaining| D[DispatcherServlet] D --> K[Controller]
Because the filter sits after Security, it can read the authenticated principal; a request that Security rejects never reaches it. The section on ordering shows what that costs and what the alternative costs.
Dependencies
Spring Boot’s dependency management does not mention Bucket4j, so the version has to come from somewhere. Since 8.21.0 that
place is bucket4j_jdk17-bom, which pins every module to the same version. Import it, then declare modules without versions.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-bom</artifactId>
<version>8.21.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- import this before spring-cloud-dependencies, which manages Resilience4j 2.3.0 -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-bom</artifactId>
<version>2.4.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency><groupId>com.bucket4j</groupId><artifactId>bucket4j_jdk17-core</artifactId></dependency>
<dependency><groupId>com.bucket4j</groupId><artifactId>bucket4j_jdk17-lettuce</artifactId></dependency>
<dependency><groupId>com.bucket4j</groupId><artifactId>bucket4j_jdk17-postgresql</artifactId></dependency>
<dependency><groupId>com.bucket4j</groupId><artifactId>bucket4j_jdk17-caffeine</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-flyway</artifactId></dependency>
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-postgresql</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aspectj</artifactId></dependency>
<dependency><groupId>io.github.resilience4j</groupId><artifactId>resilience4j-spring-boot4</artifactId><version>2.4.0</version></dependency>
</dependencies>
Four details are worth knowing. The backend modules (bucket4j_jdk17-lettuce, -postgresql, -caffeine) declare their client
library as provided, so the application brings Lettuce, the PostgreSQL driver and Caffeine itself; Boot’s
spring-boot-starter-data-redis supplies Lettuce 7.5.2 and Boot manages the driver and Caffeine versions. Boot 4 renamed the
web starter to spring-boot-starter-webmvc and has no spring-boot-starter-aop: the aspect support used below comes from
spring-boot-starter-aspectj. resilience4j-bom has no entry for resilience4j-spring-boot4, which therefore needs its version, and
when spring-cloud-dependencies is also imported it must come after resilience4j-bom: with the opposite order the
Resilience4j modules resolved to 2.3.0 in the scratch project, a mix of versions. And only the artifacts you use belong in the
build: drop the PostgreSQL lines for a Redis-only service.
Plans as @ConfigurationProperties
The limits belong in configuration, not in code, so that a plan change is a deployment and not a release. A record bound with
@ConfigurationProperties and validated at startup keeps the YAML honest.
bookshelf:
rate-limit:
default-plan: free
plans:
free: { capacity: 20, period: 1m }
pro: { capacity: 200, period: 1m }
partner: { capacity: 2000, period: 1m }
catalog-import: { capacity: 2, period: 1m }
isbn: { capacity: 1000, period: 1d, refill: intervally }
api-keys:
key-free-demo: free
key-pro-demo: pro
users:
alice: pro
costs:
"[/books/search]": 5
on-backend-failure: closed
The API keys above are placeholders for the examples; real keys never live in a committed file (use an externalized
secret or a table). The record that binds them, with the per-plan BucketConfiguration supplier:
@Validated
@ConfigurationProperties("bookshelf.rate-limit")
public record RateLimitProperties(
@DefaultValue("free") @NotBlank String defaultPlan,
@NotEmpty Map<String, @Valid PlanLimit> plans,
@DefaultValue Map<String, String> apiKeys,
@DefaultValue Map<String, String> users,
@DefaultValue Map<String, Long> costs,
@DefaultValue("closed") FailureMode onBackendFailure,
Integer filterOrder) {
public enum FailureMode { OPEN, CLOSED }
public enum Refill { GREEDY, INTERVALLY }
public record PlanLimit(@Positive long capacity, @DefaultValue("1m") Duration period,
@DefaultValue("greedy") Refill refill) {
public BucketConfiguration toConfiguration() {
return BucketConfiguration.builder()
.addLimit(limit -> {
var capacityStage = limit.capacity(capacity);
return refill == Refill.GREEDY
? capacityStage.refillGreedy(capacity, period)
: capacityStage.refillIntervally(capacity, period);
})
.build();
}
/** What ProxyManager.builder().build(key, supplier) takes: called only when the bucket must be created. */
public Supplier<BucketConfiguration> supplier() {
BucketConfiguration configuration = toConfiguration();
return () -> configuration;
}
}
public long costOf(String path) { return costs.getOrDefault(path, 1L); }
public String planOfUser(String username) { return users.getOrDefault(username, defaultPlan); }
public String planOfApiKey(String apiKey) { return apiKeys.get(apiKey); }
}
Source: Spring Boot reference: Type-safe Configuration Properties and Bucket4j reference: Bucket configuration.
@ConfigurationPropertiesScan (or @EnableConfigurationProperties) registers the record. BucketConfiguration is immutable,
so one instance per plan can be shared by every request, and the supplier is only called when the proxy has to create a bucket
that does not exist yet. A test bound this YAML and checked that free has capacity 20 and that /books/search costs 5 while
/books costs 1. A negative capacity or a missing plans map fails at startup instead of at the first request.
The ProxyManager bean
One ProxyManager<String> bean, and the rest of the application depends only on that type. Which store stands behind it is a
configuration choice, so the same code runs on a laptop and in production.
Local Caffeine for development
@Bean
@ConditionalOnProperty(prefix = "bookshelf.rate-limit", name = "store", havingValue = "caffeine", matchIfMissing = true)
ProxyManager<String> localProxyManager() {
return Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder().maximumSize(100_000))
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.build();
}
Source: Bucket4j reference: ProxyManager and the Bucket4jCaffeine source at 8.21.0.
It limits one JVM only, which is exactly what a unit test wants, and the filter and tests below ran on it.
Redis with Lettuce, from spring.data.redis.*
Spring Boot’s Lettuce support builds a LettuceConnectionFactory for Spring Data Redis; it does not create a bare
io.lettuce.core.RedisClient bean (the scratch project confirmed that the application context holds none). Bucket4j’s Lettuce
module needs a RedisClient, so the application builds one, and it should take the connection settings from the same
spring.data.redis.* properties Boot reads so that there is one place to configure Redis. In Boot 4 those properties are bound
to DataRedisProperties.
@Bean(destroyMethod = "shutdown")
@ConditionalOnProperty(prefix = "bookshelf.rate-limit", name = "store", havingValue = "redis")
RedisClient bucket4jRedisClient(DataRedisProperties redis) {
RedisURI.Builder uri = RedisURI.builder()
.withHost(redis.getHost()).withPort(redis.getPort()).withDatabase(redis.getDatabase());
if (redis.getPassword() != null) {
uri.withPassword(redis.getPassword().toCharArray());
}
if (redis.getTimeout() != null) {
uri.withTimeout(redis.getTimeout());
}
return RedisClient.create(uri.build());
}
@Bean
@ConditionalOnProperty(prefix = "bookshelf.rate-limit", name = "store", havingValue = "redis")
ProxyManager<String> redisProxyManager(RedisClient bucket4jRedisClient,
@Value("${bookshelf.rate-limit.recovery:RECONSTRUCT}") RecoveryStrategy recovery) {
return Bucket4jLettuce.casBasedBuilder(bucket4jRedisClient)
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.requestTimeout(Duration.ofMillis(250))
.defaultRecoveryStrategy(recovery)
.build()
.withMapper(key -> key.getBytes(StandardCharsets.UTF_8));
}
Source: Spring Boot reference: Connecting to Redis, Bucket4j reference: Lettuce and Client-side configuration.
casBasedBuilder(RedisClient) yields a ProxyManager<byte[]>; withMapper adapts it to the String keys the rest of the
code uses. A RedisURI built from host, port, database, password and timeout does not cover spring.data.redis.url, SSL or
Sentinel and Cluster settings (DataRedisProperties exposes getUrl(), getSsl(), getSentinel() and getCluster()); a
service that uses them maps those properties too, or builds the RedisURI with RedisURI.create(url). The Redis connection of Spring Data and this one are independent connections to the same server. The
settings that matter in the builder are explained on the distributed page; here the requestTimeout of 250 ms is what turns a
hung Redis into an exception instead of a stuck thread, and the outage section depends on it.
Run against a Redis container, 20 calls with a free key were allowed and the 21st rejected. The only key stored was
key: followed by 16 hex characters, a fingerprint of the API key (see key resolution), and PTTL showed about 660 seconds: the
10 minutes kept after the bucket is full, plus the time the bucket needs to refill.
PostgreSQL with Flyway
When a database is the only shared infrastructure, Boot’s DataSource is enough. Bucket4j never creates the table; a Flyway
migration does, and with String keys the id column must be a string. The migration must exist before the manager is first
used, which Boot guarantees because Flyway runs when the context starts.
CREATE TABLE IF NOT EXISTS bucket (
id VARCHAR(255) PRIMARY KEY,
state BYTEA,
expires_at BIGINT
);
CREATE INDEX IF NOT EXISTS bucket_expires_at ON bucket (expires_at);
@Bean
@ConditionalOnProperty(prefix = "bookshelf.rate-limit", name = "store", havingValue = "postgres")
ProxyManager<String> postgresProxyManager(DataSource dataSource) {
return Bucket4jPostgreSQL.selectForUpdateBasedBuilder(dataSource)
.primaryKeyMapper(PrimaryKeyMapper.STRING)
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.build();
}
Against a PostgreSQL 17 container, Flyway applied migration 1 successfully, the manager was a
PostgreSQLSelectForUpdateBasedProxyManager, and after 21 requests the table held one row whose id was the fingerprinted key
and whose expires_at was set. The rest of the code did not change. The table, the key-type rules and the cleanup of expired
rows (the JDBC managers never delete them) are on the backends page; this
manager does not support the asynchronous API, so the WebFlux filter below needs Redis or another asynchronous backend.
When the stored bucket disappears
A bucket vanishes when Redis restarts without persistence, when a key is evicted, or when its TTL passes. What the next request
sees depends on the recovery strategy of the proxy. With RECONSTRUCT, the default and the value set above, the proxy creates
the bucket again, full. The test exhausted a pro key (179 tokens remaining after 21 requests), ran FLUSHALL, and the next
request reported 199 of 200: a store that loses data silently resets every limit. With THROW_BUCKET_NOT_FOUND_EXCEPTION a proxy
that has already used the bucket fails with BucketNotFoundException, which the service can turn into a decision.
The subtle part, observed here, is that the strict mode only helps a proxy that is kept. wasInitialized lives in each
BucketProxy object: the filter above builds a proxy per request, and with the strict default a brand-new key worked and
a flushed one was silently recreated (both returned 200). Only a proxy built once and reused threw BucketNotFoundException
after the flush. The strict mode therefore belongs on limits whose proxy is long-lived, such as the ISBN quota below, and the
per-request pattern should be used with RECONSTRUCT and a store that is made durable. Source: the DefaultBucketProxy and
RecoveryStrategy sources at 8.21.0 and
Client-side configuration.
Servlet: a filter or an interceptor
Spring MVC offers two places to intercept a request, and both work.
| Aspect | OncePerRequestFilter |
HandlerInterceptor |
|---|---|---|
Runs for |
Every request the container routes to the application, including 404s and static files |
Only requests that Spring MVC maps to a handler |
Position |
Anywhere in the servlet filter order, before or after Spring Security |
Always inside |
Knows the controller method |
No, only the URL |
Yes ( |
Rejects by |
Writing the response, or delegating to |
Throwing: the normal |
Typical use |
Edge limits that must also count failed logins and unknown URLs |
Per-endpoint costs that are annotations on the controller |
The filter is the general tool; the interceptor is the tidier one when every limit hangs on a controller method.
Both call the same RateLimitService.
public class RateLimitFilter extends OncePerRequestFilter {
private final RateLimitService limits;
private final ClientIdentifier clients;
private final RateLimitProperties properties;
private final HandlerExceptionResolver exceptionResolver;
// constructor omitted
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getRequestURI().startsWith("/actuator/health"); // probes are never limited
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Client client = clients.identify(request);
try {
Decision decision = limits.check(client.key(), client.plan(), properties.costOf(request.getRequestURI()));
if (!decision.allowed()) {
throw new RateLimitExceededException(decision);
}
if (decision.remaining() >= 0) {
response.setHeader("X-Rate-Limit-Remaining", String.valueOf(decision.remaining()));
}
} catch (RateLimitExceededException | RateLimitUnavailableException e) {
// a filter runs before DispatcherServlet: hand the exception to the same @ExceptionHandler methods
exceptionResolver.resolveException(request, response, null, e);
return;
}
chain.doFilter(request, response);
}
}
Source: Spring Framework reference: Filters.
The try block wraps only the limit check, not chain.doFilter, so an exception thrown by the controller is never mistaken
for a rate-limit decision. The resolver is the bean named handlerExceptionResolver that Spring MVC registers
(@Qualifier("handlerExceptionResolver")); passing null as the handler makes every @ControllerAdvice method eligible.
A Filter bean is registered by Boot with the default order, so to control the position wrap the filter in a
FilterRegistrationBean and create the filter inside it (do not also expose it as a @Component, or it would be registered
twice).
@Bean
@ConditionalOnProperty(prefix = "bookshelf.rate-limit", name = "mechanism", havingValue = "filter", matchIfMissing = true)
FilterRegistrationBean<RateLimitFilter> rateLimitFilter(RateLimitService limits, ClientIdentifier clients,
RateLimitProperties properties, SecurityFilterProperties security,
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
var registration = new FilterRegistrationBean<>(new RateLimitFilter(limits, clients, properties, resolver));
// Spring Security's chain is registered at spring.security.filter.order (default -100): +1 runs after it
int order = properties.filterOrder() != null ? properties.filterOrder() : security.getOrder() + 1;
registration.setOrder(order);
registration.addUrlPatterns("/*");
return registration;
}
In Boot 4 the filter-order properties are bound to org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties
(DEFAULT_FILTER_ORDER is -100). The interceptor reads the cost from a small annotation on the controller method and is
registered through WebMvcConfigurer:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
public @interface RateLimitCost { long value(); }
public class RateLimitInterceptor implements HandlerInterceptor {
// fields and constructor as in the filter
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod method)) {
return true; // static resources, error pages
}
RateLimitCost cost = method.getMethodAnnotation(RateLimitCost.class);
Client client = clients.identify(request);
Decision decision = limits.check(client.key(), client.plan(), cost == null ? 1 : cost.value());
if (!decision.allowed()) {
throw new RateLimitExceededException(decision); // MVC maps it through @ExceptionHandler
}
if (decision.remaining() >= 0) {
response.setHeader("X-Rate-Limit-Remaining", String.valueOf(decision.remaining()));
}
return true;
}
}
@Bean
@ConditionalOnProperty(prefix = "bookshelf.rate-limit", name = "mechanism", havingValue = "interceptor")
WebMvcConfigurer rateLimitInterceptor(RateLimitService limits, ClientIdentifier clients) {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new RateLimitInterceptor(limits, clients)).addPathPatterns("/books/**", "/account/**");
}
};
}
Both mechanisms produced the same results for the five-token search (@RateLimitCost(5) on the controller method for the
interceptor, costs in YAML for the filter): 200/15 200/10 200/5 200/0 429/0 as status and remaining tokens on five calls
from a free client. They differ for requests outside the controllers: the filter counted a request to an unknown path (a 404
that still carried X-Rate-Limit-Remaining: 19), the interceptor did not count it. A bot probing random URLs is therefore
limited by the filter and invisible to the interceptor.
Who is asking: key resolution
The key decides what shares a bucket, and is the most security-relevant line of the integration. The Bookshelf uses the first identity that is trustworthy:
public Client identify(HttpServletRequest request) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken)) {
return new Client("user:" + authentication.getName(), properties.planOfUser(authentication.getName()));
}
String apiKey = request.getHeader("X-API-Key");
String plan = apiKey == null ? null : properties.planOfApiKey(apiKey);
if (plan != null) {
return new Client("key:" + fingerprint(apiKey), plan); // never store the secret itself as a key
}
// unknown keys fall back to the address: random keys must not create unlimited buckets
return new Client("ip:" + request.getRemoteAddr(), properties.defaultPlan());
}
static String fingerprint(String secret) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash, 0, 8);
} catch (NoSuchAlgorithmException e) { // every Java platform must provide SHA-256
throw new IllegalStateException(e);
}
}
Observed with the filter after Security, one request each: the authenticated user alice (pro plan) reported 199 tokens
remaining, a pro API key 199, an anonymous caller 19 (free, keyed by address), and a request with an invented API key 18, which
is the same address bucket. The last result is the point of the unknown-key rule: if any X-API-Key value created its own
bucket, a client could send a new random key on every request and never be limited, and the store would fill with garbage. The
fingerprint keeps the API key out of Redis keys, logs and KEYS * output.
An authenticated principal comes from SecurityContextHolder, which is a thread-local that Security fills and clears. That is
fine in a servlet filter on the request thread; a method-level limiter that runs on another thread must receive the identity as
an argument.
The client address behind proxies
Behind a load balancer, request.getRemoteAddr() is the balancer, and every anonymous client shares one bucket. Spring Boot’s
server.forward-headers-strategy decides whether X-Forwarded-For is applied. Four configurations were run, sending two
different X-Forwarded-For values from the loopback address:
| Configuration | Observed |
|---|---|
default ( |
Header ignored: both requests used |
|
Header applied: two clients got their own bucket (19 and 19 remaining), and the first again reported 18 |
|
Header ignored: the clients shared a bucket, so a spoofed header from an untrusted peer changes nothing |
|
Header applied from any peer: two clients got their own bucket (19 and 19) |
The consequence is trust. A rate limiter keyed by an attacker-controlled header can be defeated by changing the header, so
the application must only honour X-Forwarded-For from its own proxy. The native strategy does that with its list of
trusted proxies (the default list covers loopback and private ranges, so set internal-proxies to the real balancer in
production); the framework filter has no such list and is only safe when the application is reachable through the proxy
alone. Source: Spring Boot how-to: Use behind a proxy server.
Order relative to Spring Security
A servlet filter registered through Boot sits in a single ordered list with Spring Security’s springSecurityFilterChain, which
Boot registers at spring.security.filter.order (default -100). The limiter’s order relative to it decides what it can see
and what it counts.
| Observed | After Security (order -99, the default here) | Before Security (order -101) |
|---|---|---|
Principal available to the key |
Yes: |
No: the same request fell back to the address, 19 remaining |
25 requests with wrong credentials to a protected URL |
25 times 401, never limited |
20 times 401, then 5 times 429 |
Requests that Security rejects |
Never reach the limiter, consume nothing |
Counted against the address |
Neither position is wrong; they serve different purposes. A limiter after Security is the per-user and per-plan quota, and
the filter here is that. But a credential-stuffing attacker sending wrong passwords is rejected by Security with a 401 before the
limiter runs, so after-Security alone does not slow brute force. To limit failed logins, register a second, address-keyed limit
before Security (a coarse filter at order -101, as the test did through bookshelf.rate-limit.filter-order), or use
Spring Security’s own login-attempt controls. The interceptor is always after Security because it runs inside
DispatcherServlet, and the test confirmed that 25 failed logins were never counted by it either.
The order is a number you must be able to see: set bookshelf.rate-limit.filter-order explicitly in a deployment that has both a
before and an after limiter. Source: Spring Boot reference: spring.security.filter.order
and Spring Security in the SpringBoot reference.
Responses
A rejection must tell the client when to come back. The Bookshelf sends, for a rejected request:
-
status 429 Too Many Requests (RFC 6585),
-
Retry-Afterin whole seconds, rounded up (RFC 9110), -
X-Rate-Limit-RemainingandX-Rate-Limit-Retry-After-Seconds, the names the community starter uses so that clients of either implementation read the same headers, -
a body of type
application/problem+json(RFC 9457).
The decision carries the numbers in HTTP units; Retry-After is the nanoseconds to the next refill rounded up, never below 1.
public record Decision(boolean allowed, long remaining, long retryAfterSeconds, String plan) {
static Decision of(ConsumptionProbe probe, String plan) {
long seconds = Math.max(1, (long) Math.ceil(probe.getNanosToWaitForRefill() / (double) Duration.ofSeconds(1).toNanos()));
return new Decision(probe.isConsumed(), probe.getRemainingTokens(), probe.isConsumed() ? 0 : seconds, plan);
}
}
@RestControllerAdvice
class RateLimitExceptionHandler {
@ExceptionHandler(RateLimitExceededException.class)
ResponseEntity<ProblemDetail> tooManyRequests(RateLimitExceededException e) {
Decision d = e.decision();
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.TOO_MANY_REQUESTS,
"The %s plan allows fewer requests than you sent. Retry in %d seconds.".formatted(d.plan(), d.retryAfterSeconds()));
problem.setType(URI.create("https://bookshelf.example/problems/rate-limit-exceeded"));
problem.setTitle("Rate limit exceeded");
problem.setProperty("plan", d.plan());
problem.setProperty("retryAfterSeconds", d.retryAfterSeconds());
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(d.retryAfterSeconds()))
.header("X-Rate-Limit-Remaining", "0")
.header("X-Rate-Limit-Retry-After-Seconds", String.valueOf(d.retryAfterSeconds()))
.body(problem);
}
@ExceptionHandler(RateLimitUnavailableException.class)
ResponseEntity<ProblemDetail> storeUnavailable(RateLimitUnavailableException e) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
"The rate limiter cannot decide right now.");
problem.setTitle("Rate limiter unavailable");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).header(HttpHeaders.RETRY_AFTER, "5").body(problem);
}
}
Source: Spring Framework reference: Error responses, RFC 9457: Problem Details for HTTP APIs, RFC 6585: 429 Too Many Requests and RFC 9110: Retry-After.
The twenty-first request of a free key, observed through the filter, after twenty successes whose remaining counts went
19 18 17 … 1 0:
HTTP/1.1 429
Retry-After: 3
X-Rate-Limit-Remaining: 0
X-Rate-Limit-Retry-After-Seconds: 3
Content-Type: application/problem+json
{"detail":"The free plan allows fewer requests than you sent. Retry in 3 seconds.","instance":"/books",
"status":429,"title":"Rate limit exceeded","type":"https://bookshelf.example/problems/rate-limit-exceeded",
"plan":"free","retryAfterSeconds":3}
Three seconds is one token of a bucket that refills 20 per minute. The .example type URI is a placeholder: a real service
publishes a page that explains the problem and uses that URL.
The X-Rate-Limit-* pair is a convention, not a standard. There is an IETF Internet-Draft for standard fields
(RateLimit-Policy and RateLimit, from the HTTP API working group). It is still a draft, and its field names changed between
revisions (earlier ones used RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset), so a service that adopts it should
check the current revision, and a client should not depend on it yet. Retry-After is the only header with a
standard, stable meaning here, and the one a generic HTTP client honours. Source:
IETF draft: RateLimit header fields for HTTP.
Reactive: a WebFilter with AsyncProxyManager
A WebFlux request is not allowed to park a thread on a network call, so the limiter must use the asynchronous API.
ProxyManager.asAsync() returns an AsyncProxyManager whose buckets answer with a CompletableFuture, which Mono.fromFuture
adapts. Redis through Lettuce supports it; the JDBC managers do not.
@Bean
AsyncProxyManager<String> asyncProxyManager(RedisClient bucket4jRedisClient) {
return Bucket4jLettuce.casBasedBuilder(bucket4jRedisClient)
.expirationAfterWrite(ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
.requestTimeout(Duration.ofMillis(250))
.build()
.withMapper((String key) -> key.getBytes(StandardCharsets.UTF_8))
.asAsync();
}
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
class RateLimitWebFilter implements WebFilter {
private static final BucketConfiguration FREE = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(5).refillGreedy(5, Duration.ofMinutes(1)))
.build();
private final AsyncProxyManager<String> proxyManager;
private final RateLimitProperties properties;
RateLimitWebFilter(AsyncProxyManager<String> proxyManager, RateLimitProperties properties) {
this.proxyManager = proxyManager;
this.properties = properties;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String key = clientKey(exchange);
return Mono.defer(() -> Mono.fromFuture(
proxyManager.builder()
.build(key, () -> CompletableFuture.completedFuture(FREE))
.tryConsumeAndReturnRemaining(1))) // no thread parks on the network call
.flatMap(probe -> probe.isConsumed() ? allow(exchange, chain, probe) : reject(exchange, probe));
}
private static Mono<Void> allow(ServerWebExchange exchange, WebFilterChain chain, ConsumptionProbe probe) {
exchange.getResponse().getHeaders().set("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
return chain.filter(exchange);
}
private static Mono<Void> reject(ServerWebExchange exchange, ConsumptionProbe probe) {
long seconds = Math.max(1, (long) Math.ceil(probe.getNanosToWaitForRefill() / 1_000_000_000d));
var response = exchange.getResponse();
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
response.getHeaders().set(HttpHeaders.RETRY_AFTER, String.valueOf(seconds));
response.getHeaders().setContentType(MediaType.APPLICATION_PROBLEM_JSON);
String body = "{\"status\":429,\"title\":\"Rate limit exceeded\",\"retryAfterSeconds\":" + seconds + "}";
return response.writeWith(Mono.just(response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8))));
}
private String clientKey(ServerWebExchange exchange) {
String apiKey = exchange.getRequest().getHeaders().getFirst("X-API-Key");
if (apiKey != null && properties.planOfApiKey(apiKey) != null) {
return "key:" + ClientIdentifier.fingerprint(apiKey); // issued keys only, never the secret itself
}
// unknown or missing keys fall back to the address: random keys must not create unlimited buckets
InetSocketAddress address = exchange.getRequest().getRemoteAddress();
return "ip:" + (address == null ? "unknown" : address.getAddress().getHostAddress());
}
}
In a started WebFlux application on Redis, five calls with one key returned remaining 4 3 2 1 0 and the sixth was a 429 with
Retry-After: 12 (one token of a bucket that refills 5 per minute) and application/problem+json. A WebFilter runs before
the annotated-controller machinery, so @ExceptionHandler methods do not apply to what it writes; the filter writes the body
itself, or fails the exchange with an exception and lets the error handling of WebFlux render it. The principal in WebFlux is not in a thread-local: read it with exchange.getPrincipal() or
ReactiveSecurityContextHolder, and mind that a WebFilter ordered before Spring Security’s web filter does not have it.
The clientKey applies the same rules as the servlet ClientIdentifier: only an issued API key gets its own bucket, keyed by
its SHA-256 fingerprint, and an unknown or missing key is limited by address, so inventing keys does not escape the limit.
A method-level limiter
Not every limited operation is an HTTP request: a scheduled import, a message listener and a service method called from
several controllers all need a limit. A custom annotation and an aspect give the @RateLimiting-style convenience of the
starter in about thirty lines, with the Bookshelf’s own plans and exception. Boot 4 supplies the aspect support through
spring-boot-starter-aspectj (the spring-boot-starter-aop of Boot 3 does not exist).
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
public @interface RateLimited {
String plan(); // an entry of bookshelf.rate-limit.plans
String key(); // SpEL over the arguments, e.g. "#libraryId"
long tokens() default 1;
}
@Aspect
@Component
class RateLimitAspect {
private final RateLimitService limits;
private final ExpressionParser parser = new SpelExpressionParser();
private final ParameterNameDiscoverer names = new DefaultParameterNameDiscoverer();
@Around("@annotation(rateLimited)")
Object limit(ProceedingJoinPoint call, RateLimited rateLimited) throws Throwable {
var method = ((MethodSignature) call.getSignature()).getMethod();
var context = new MethodBasedEvaluationContext(call.getTarget(), method, call.getArgs(), names);
String subject = parser.parseExpression(rateLimited.key()).getValue(context, String.class);
Decision decision = limits.check("method:" + rateLimited.plan() + ":" + subject, rateLimited.plan(), rateLimited.tokens());
if (!decision.allowed()) {
throw new RateLimitExceededException(decision);
}
return call.proceed();
}
}
@Service
public class CatalogImportService {
@RateLimited(plan = "catalog-import", key = "#libraryId")
public String importCatalog(String libraryId) {
return "imported " + libraryId;
}
}
With the plan at 2 per minute, two imports for one library succeeded, the third threw RateLimitExceededException with a
retry of 30 seconds, and another library had its own bucket. The usual proxy rules apply: a call from another method of the
same class bypasses the aspect (self-invocation), and the annotated method must be public on a Spring bean. A
RateLimitExceededException thrown from a controller’s service flows through the same @ExceptionHandler as the one thrown
by the filter, so an HTTP endpoint that uses the annotation gets the same 429.
The shared outbound quota
The Bookshelf calls an ISBN provider that allows 1000 calls per day for the whole account. That quota is shared by every
replica, so it is one bucket in the shared store, and the natural place to spend from it is where every outbound call passes:
a ClientHttpRequestInterceptor on the RestClient.
public class IsbnQuotaInterceptor implements ClientHttpRequestInterceptor {
private final BucketProxy quota; // built once, reused for every call
private final Duration maxWait;
public IsbnQuotaInterceptor(ProxyManager<String> proxyManager, RateLimitProperties.PlanLimit isbn, Duration maxWait) {
this.quota = proxyManager.builder().build("quota:isbn", isbn.supplier());
this.maxWait = maxWait;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
if (maxWait.isZero()) {
ConsumptionProbe probe = quota.tryConsumeAndReturnRemaining(1);
if (!probe.isConsumed()) {
long seconds = Math.max(1, (long) Math.ceil(probe.getNanosToWaitForRefill() / 1_000_000_000d)); // round up
throw new QuotaExhaustedException(seconds);
}
} else {
try {
if (!quota.asBlocking().tryConsume(1, maxWait)) { // parks the calling (virtual) thread, bounded
throw new QuotaExhaustedException(maxWait.toSeconds());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for the ISBN quota", e);
}
}
return execution.execute(request, body);
}
}
RestClient isbn = RestClient.builder().baseUrl("https://isbn.example")
.requestInterceptor(new IsbnQuotaInterceptor(proxyManager, properties.plans().get("isbn"), Duration.ZERO))
.build();
Two interceptors built on the same ProxyManager (as two replicas would be) with a quota of 3 behaved as one bucket: the calls
were accepted by A, B, A, and the fourth, on B, threw QuotaExhaustedException with "next call possible in 86400s" (the wait rounded up). The
bucket uses refillIntervally, which matches how a provider’s daily quota resets, and maxWait of zero fails immediately so
the caller can serve a cached answer. With a positive maxWait the thread waits for a token for up to that time: six calls
against a quota of two per second took about two seconds (two immediately, then one every 500 ms). Waiting is the right
behaviour for a batch job and the wrong one on a request thread that has a latency budget; with virtual threads it is cheap
(see the last section). The https://isbn.example URL is a placeholder, never a real provider.
When the store is down
A distributed limiter adds a dependency to the request path, and a dependency fails. The decision that matters is what the request does when the limiter cannot answer, and it has two answers with opposite risks.
| Policy | Effect when the store is down | Choose it for |
|---|---|---|
Fail open |
Requests pass without a limit; no |
Availability-first APIs, where a short period without limits is cheaper than an outage; limits that only protect cost, not safety |
Fail closed |
Requests get a 503 |
Security limits (login attempts, OTP, password reset) and quotas whose breach costs money, such as the outbound ISBN quota |
Whatever is chosen, one more thing must be bounded: how long a request waits before the policy applies. A hung Redis does not
refuse connections, it stops answering, so without a timeout each request would wait for the client’s default. The
requestTimeout(Duration.ofMillis(250)) of the manager caps that wait, and a Resilience4j CircuitBreaker around the proxy
turns "every request waits 250 ms to learn that Redis is down" into "after a few failures, requests fail at once". The
breaker only sees store failures, because a rejected consumption is a normal result, not an exception.
@Service
public class RateLimitService {
private final ProxyManager<String> proxyManager;
private final RateLimitProperties properties;
private final CircuitBreaker circuitBreaker;
RateLimitService(ProxyManager<String> proxyManager, RateLimitProperties properties, CircuitBreakerRegistry registry) {
this.proxyManager = proxyManager;
this.properties = properties;
this.circuitBreaker = registry.circuitBreaker("bucket4j");
}
public Decision check(String key, String plan, long tokens) {
var limit = properties.plans().get(plan);
try {
ConsumptionProbe probe = circuitBreaker.executeSupplier(() ->
proxyManager.builder().build(key, limit.supplier()).tryConsumeAndReturnRemaining(tokens));
return Decision.of(probe, plan);
} catch (RuntimeException storeFailure) { // includes CallNotPermittedException
if (properties.onBackendFailure() == FailureMode.OPEN) {
log.warn("Rate-limit store unavailable ({}), failing open", storeFailure.getClass().getSimpleName());
return Decision.unchecked(plan);
}
throw new RateLimitUnavailableException(storeFailure);
}
}
}
resilience4j:
circuitbreaker:
instances:
bucket4j:
sliding-window-type: COUNT_BASED
sliding-window-size: 4
minimum-number-of-calls: 4
failure-rate-threshold: 50
wait-duration-in-open-state: 2s
permitted-number-of-calls-in-half-open-state: 1
Source: Resilience4j: CircuitBreaker, the Resilience4j CircuitBreaker page and fallbacks and composition.
The breaker is configured in the usual Resilience4j way; the service gets it from the auto-configured
CircuitBreakerRegistry. The tests paused the Redis container (a hung server, the case a refused connection does not
reproduce) and sent six requests under each policy:
| Request | Fail closed | Fail open |
|---|---|---|
1 to 3 |
503 after about 270 ms each (the 250 ms timeout), |
200 after about 270 ms each, without |
4 to 6 |
503 after 3 to 5 ms: the breaker was open and short-circuited |
200 after 1 to 3 ms |
The fourth call failed fast because the four-call window was full of failures and the breaker was open. With the container unpaused and the two-second wait elapsed, the next request returned 200 with 19 tokens remaining and the breaker closed again: the half-open probe succeeded and normal limiting resumed, with the bucket as it was before the outage. Fail-open therefore has a visible cost, a log line per failure and no remaining header, and the metrics page Bucket4j observability explains how to alert on it.
A third option sits between the two: fall back to a local Bucket per instance when the store is down, which keeps
approximate limits (each replica enforces the full limit, so the service-wide limit is multiplied by the replica count) without
refusing traffic. It is more code and the right choice mainly for per-client limits on a large fleet.
Virtual threads and blocking consumption
With spring.threads.virtual.enabled=true, Boot 4 serves requests on virtual threads: the observed handler thread was a
VirtualThread named tomcat-handler-0. Blocking calls then cost almost nothing while they wait, which makes the blocking
API of Bucket4j attractive: asBlocking().tryConsume(1, maxWait) parks the virtual thread until a token is available (or the
time runs out) instead of returning a rejection.
var config = BucketConfiguration.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofSeconds(1))).build();
var bucket = proxyManager.builder().build("blocking:demo", () -> config);
AtomicInteger admitted = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 50; i++) {
executor.submit(() -> {
try {
if (bucket.asBlocking().tryConsume(1, Duration.ofSeconds(10))) {
admitted.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
All fifty were admitted in about 4.0 seconds: ten at once and ten per second after that, exactly the refill rate, with no
over-admission. Blocking consumption is for work you are willing to delay: an outbound call, a batch step, a queue consumer.
It is the wrong behaviour for the inbound limiter of an interactive API, where a client should be told 429 with Retry-After
rather than kept waiting, and a blocked request still holds its connection and any resources it opened. Bound every wait
(tryConsume(1, maxWait), never an unbounded consume), and remember that against a remote bucket each attempt is also a
store call. A reactive application must not block at all and uses the asynchronous API shown earlier.
The Bookshelf end to end
Put together, the service behaves as follows (every line observed in the runs described above, on the filter after Security with the local manager unless the store is named):
| Request | Result |
|---|---|
20 calls to |
200, remaining |
The 21st call |
429, |
|
200 with remaining 15, 10, 5, 0, then 429 |
|
200, remaining 199 |
|
Treated as an address: free plan, shared bucket |
|
Two buckets |
|
Third call throws, retry in 30 s |
Outbound ISBN call 4 of a 3-call quota, from either replica |
|
Any request while Redis hangs, fail closed |
503 with |
Any request while Redis hangs, fail open |
200 without |
Setting bookshelf.rate-limit.store to redis or postgres changes the store without changing a class, and the same filter
passed on Redis (20 calls, then a 429 with Retry-After: 3) and on PostgreSQL (the same, with one row in the table). Before
production, the checks that this page leaves out are on the
production checklist, the tests that cover the same ground in Bucket4j testing,
and how to count the 429s and fail-open events is in observability.
For the Redis side of the application see Spring Boot with Redis as a cache,
and for the configuration conventions configuration and profiles. The
error format follows REST APIs in Spring Boot, and the test setup is the one of
integration tests with Testcontainers.
Pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
Assuming Boot provides a |
Bucket4j’s Lettuce builder has nothing to build from; only a |
Define a |
No |
A hung Redis makes every request thread wait; the breaker never opens because nothing fails |
Set |
Exposing the filter as a |
Two instances can be registered and each consumes tokens |
Create the filter only inside the registration bean |
Not setting the filter order |
The limiter’s position relative to Security is whatever the default gives |
Set the order from |
Limiter only after Spring Security |
Failed logins are rejected with 401 before the limiter and never counted |
Add an address-keyed limit before Security for login endpoints |
Limiter before Spring Security that keys by user |
The principal is not yet known: authenticated users are limited as anonymous clients |
Key by address there, or place the limiter after Security |
Using the raw API key as the bucket key |
The secret ends up in Redis keys, logs and monitoring |
Store a fingerprint (a truncated SHA-256) |
A new bucket for every unknown API key |
A client sends random keys to avoid limits, and the store fills up |
Resolve unknown keys to the address bucket |
Trusting |
Clients choose their own bucket by setting the header |
|
Reading |
The principal is missing in async code and the limit falls back to the address |
Pass the identity as an argument, or read it on the request thread |
Throwing from a filter and expecting |
The filter runs before |
Call |
|
The strict mode never fires; a lost bucket is silently recreated |
Keep and reuse the proxy, or use |
Fail-open for a login or OTP limit |
An outage of the limiter is an outage of the protection |
Fail closed for security limits |
|
Clients retry immediately and are rejected again |
Round up to at least one second |
Annotation limiter called from another method of the same class |
The aspect is skipped and nothing is limited |
Call it through the bean, or limit at the entry point |
Blocking |
Clients wait instead of being told to back off |
Return 429, and block only for batch and outbound work, with a bounded wait |
|
Deprecated in 8.21.0 |
|
What changed
-
Spring Boot 4: the web starter is
spring-boot-starter-webmvc;spring-boot-starter-aopis replaced byspring-boot-starter-aspectj; the Redis, security and servlet classes moved into modular packages (DataRedisPropertiesinorg.springframework.boot.data.redis.autoconfigure,SecurityFilterPropertiesinorg.springframework.boot.security.autoconfigure.web.servlet); Testcontainers 2 renamed its modules and packages. -
Bucket4j 8.21.0:
bucket4j_jdk17-bomis new;build(key, config)andBandwidth.simpleare deprecated in favour ofbuild(key, supplier)andaddLimit(limit → …); the backend builders (Bucket4jLettuce.casBasedBuilder,Bucket4jPostgreSQL.selectForUpdateBasedBuilder,Bucket4jCaffeine.builderFor) replaced the older constructors. -
Jakarta: the reference’s servlet examples use
javax.servlet; on Boot 4 the packages arejakarta.servlet. -
The full list is on migration and what’s changed.