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:

Table 1. The parts of a manual integration
Part Job

RateLimitProperties

The plans, who is on which plan, what each path costs, what to do when the store fails

ProxyManager<String> bean

The connection to the store, with the client-side settings (expiry, timeout, recovery)

ClientIdentifier

Turns a request into a bucket key and a plan

RateLimitService

The only class that consumes tokens; wraps the call in a Resilience4j CircuitBreaker and applies fail-open or fail-closed

RateLimitFilter or RateLimitInterceptor

Servlet entry point; adds X-Rate-Limit-Remaining or throws

@RestControllerAdvice

Turns the exception into a 429 or 503 ProblemDetail with the right headers

RateLimitWebFilter

The reactive equivalent, on an AsyncProxyManager

@RateLimited and an aspect

Limits non-HTTP entry points

IsbnQuotaInterceptor

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:

flowchart LR C[Client] --> S[Spring Security chain
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.

pom.xml: BOMs and the modules of the Bookshelf
<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.

application.yml: plans, keys, users, costs and the failure policy
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:

RateLimitProperties.java
@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); }
}

@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

A local manager: no Docker, no network
@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.

A RedisClient and a ProxyManager built from spring.data.redis.*
@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));
}

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.

src/main/resources/db/migration/V1__bucket4j_bucket.sql
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);
A select-for-update manager over Boot’s DataSource
@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.

Table 2. Servlet filter versus HandlerInterceptor
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 DispatcherServlet, so after the whole Security chain

Knows the controller method

No, only the URL

Yes (HandlerMethod: annotations, return type)

Rejects by

Writing the response, or delegating to HandlerExceptionResolver

Throwing: the normal @ExceptionHandler methods handle it

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.

RateLimitFilter.java
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);
    }
}

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).

RateLimitWebConfig.java: position the filter explicitly
@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:

RateLimitInterceptor.java and its registration
@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:

ClientIdentifier.java
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:

Table 3. Client address and server.forward-headers-strategy
Configuration Observed

default (none)

Header ignored: both requests used ip:127.0.0.1, the second reported one token fewer than the first

native (Tomcat’s remote IP valve), peer is a trusted proxy

Header applied: two clients got their own bucket (19 and 19 remaining), and the first again reported 18

native, with server.tomcat.remoteip.internal-proxies not matching the peer

Header ignored: the clients shared a bucket, so a spoofed header from an untrusted peer changes nothing

framework (ForwardedHeaderFilter)

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.

Table 4. The rate-limit filter before or after the Security chain
Observed After Security (order -99, the default here) Before Security (order -101)

Principal available to the key

Yes: alice got the pro plan, 199 remaining

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-After in whole seconds, rounded up (RFC 9110),

  • X-Rate-Limit-Remaining and X-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.

Decision.java: from a ConsumptionProbe to HTTP units
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);
    }
}
The exception handler that every path shares
@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);
    }
}

The twenty-first request of a free key, observed through the filter, after twenty successes whose remaining counts went 19 18 17 …​ 1 0:

The observed 429 (headers trimmed to the relevant ones)
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.

sequenceDiagram participant C as Client participant S as Spring Security participant F as RateLimitFilter participant L as RateLimitService participant P as ProxyManager and Redis participant H as ExceptionHandler C->>S: GET /books with X-API-Key S->>F: authenticated or anonymous F->>L: check(key, plan, cost 1) L->>P: tryConsumeAndReturnRemaining(1) P-->>L: not consumed, 3 s to refill L-->>F: Decision allowed=false, retryAfter=3 F->>H: resolveException(RateLimitExceededException) H-->>C: 429 Retry-After 3, X-Rate-Limit-Remaining 0, problem+json Note over C: waits 3 s, then retries C->>S: GET /books again S->>F: pass F->>L: check(key, plan, cost 1) L->>P: tryConsumeAndReturnRemaining(1) P-->>L: consumed, 0 remaining F-->>C: 200 with X-Rate-Limit-Remaining 0

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.

The asynchronous manager
@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();
}
RateLimitWebFilter.java
@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).

RateLimited.java and RateLimitAspect.java
@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.

IsbnQuotaInterceptor.java
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.

Table 5. Fail-open versus fail-closed
Policy Effect when the store is down Choose it for

Fail open

Requests pass without a limit; no X-Rate-Limit-Remaining header

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 ProblemDetail with Retry-After

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.

RateLimitService.java: breaker, then policy
@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);
        }
    }
}
application.yml: the breaker
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

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:

Table 6. Observed during a Redis outage (the container paused)
Request Fail closed Fail open

1 to 3

503 after about 270 ms each (the 250 ms timeout), Retry-After: 5, application/problem+json

200 after about 270 ms each, without X-Rate-Limit-Remaining

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.

Fifty virtual threads waiting for tokens
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):

Table 7. The Bookshelf’s behaviour
Request Result

20 calls to /books with a free API key

200, remaining 19 down to 0

The 21st call

429, Retry-After: 3, application/problem+json

/books/search five times, free key

200 with remaining 15, 10, 5, 0, then 429

/books as alice (pro, by Basic authentication)

200, remaining 199

/books with an unknown API key

Treated as an address: free plan, shared bucket

/books with two X-Forwarded-For values, native strategy and a trusted proxy

Two buckets

catalog-import three times for one library

Third call throws, retry in 30 s

Outbound ISBN call 4 of a 3-call quota, from either replica

QuotaExhaustedException, next call in 86399 s

Any request while Redis hangs, fail closed

503 with Retry-After: 5; after the breaker opens, in a few milliseconds

Any request while Redis hangs, fail open

200 without X-Rate-Limit-Remaining

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 RedisClient

Bucket4j’s Lettuce builder has nothing to build from; only a LettuceConnectionFactory exists

Define a RedisClient bean from DataRedisProperties with destroyMethod = "shutdown"

No requestTimeout on the manager

A hung Redis makes every request thread wait; the breaker never opens because nothing fails

Set requestTimeout, then put a CircuitBreaker around the proxy

Exposing the filter as a @Component as well as through a FilterRegistrationBean

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 spring.security.filter.order plus or minus one, deliberately

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 X-Forwarded-For from anywhere

Clients choose their own bucket by setting the header

native strategy with internal-proxies set to the real balancer

Reading SecurityContextHolder off the request thread

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 @ExceptionHandler

The filter runs before DispatcherServlet, so the controller advice does not see the exception

Call HandlerExceptionResolver, or write the response directly

THROW_BUCKET_NOT_FOUND_EXCEPTION with a proxy built per request

The strict mode never fires; a lost bucket is silently recreated

Keep and reuse the proxy, or use RECONSTRUCT and make the store durable

Fail-open for a login or OTP limit

An outage of the limiter is an outage of the protection

Fail closed for security limits

retryAfter truncated to 0

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 consume in an inbound interactive request

Clients wait instead of being told to back off

Return 429, and block only for batch and outbound work, with a bounded wait

build(key, config) and Bandwidth.simple

Deprecated in 8.21.0

build(key, supplier) and addLimit(limit → …​)

What changed

  • Spring Boot 4: the web starter is spring-boot-starter-webmvc; spring-boot-starter-aop is replaced by spring-boot-starter-aspectj; the Redis, security and servlet classes moved into modular packages (DataRedisProperties in org.springframework.boot.data.redis.autoconfigure, SecurityFilterProperties in org.springframework.boot.security.autoconfigure.web.servlet); Testcontainers 2 renamed its modules and packages.

  • Bucket4j 8.21.0: bucket4j_jdk17-bom is new; build(key, config) and Bandwidth.simple are deprecated in favour of build(key, supplier) and addLimit(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 are jakarta.servlet.

  • The full list is on migration and what’s changed.