Resilience4j versus Bucket4j

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.

Both libraries have something called a rate limiter, both are Apache-2.0 Java libraries, and both work in a Spring Boot service, so the question "which one?" comes up as soon as a team needs to limit anything. The short answer is that they were built for different jobs and work best together. This page explains the difference, measures it, and shows the two side by side in the Bookshelf service.

It was written against Resilience4j 2.4.0, Bucket4j 8.21.0, Spring Boot 4.1.x (4.1.1), Spring Framework 7.0.x and Java 21, with the community bucket4j-spring-boot-starter at 0.20.0-RC1 (0.14.0 for Spring Boot 4.0) and Spring Cloud Gateway 5.0.3, and verified on 5 October 2026: Maven Central still lists 8.21.0 and 2.4.0 as the latest releases, and the starter’s Boot 4.1 line is still only the 0.20.0-RC1 pre-release. Every example on this page was run in a scratch project: the burst comparison with a controllable clock, and the combined example against Redis 7 in Testcontainers.

Two libraries, two jobs

Resilience4j is a fault-tolerance toolkit for the calls your service makes. Its six core patterns (CircuitBreaker, Retry, RateLimiter, Bulkhead, TimeLimiter, Cache) all decorate a call to a dependency. Its RateLimiter is one of them: it counts permits in this JVM so that this instance does not call a dependency more often than it should. The section’s RateLimiter page covers it in full, and its last section lists what it does not do: no per-client keys, no shared state, nothing persistent.

Bucket4j is a rate-limiting and quota library. It has one concern, how many operations a key may perform per unit of time, and it solves it with a token bucket that can live in the JVM or in a shared store (Redis, a JDBC database, a data grid). That makes it usable in two directions:

  • inbound, to give each client of your API its own quota (100 requests per hour on the free plan, 5 000 on the pro plan), enforced across every replica

  • outbound, to share one quota of a third-party API between all replicas, which a per-instance limiter cannot do

The two overlap in exactly one place: an in-process limit on outbound calls. Everywhere else they complement each other, and Bucket4j even depends on what Resilience4j provides, because a Bucket4j bucket in Redis is itself an outbound call that can be slow or fail.

The Bookshelf. API clients send requests with an X-API-Key header through Spring Cloud Gateway, whose Bucket4j filter applies a coarse per-client limit at the edge. Inside bookshelf-service, Spring Security identifies the caller, a Bucket4j limiter applies the plan per API key (free: 100 per hour plus 10 per second, 429 with Retry-After) and the controllers serve the request, with search costing 5 tokens. On the outbound side, Resilience4j wraps each call to the ISBN provider and to pricing-service with CircuitBreaker, Retry honouring Retry-After, TimeLimiter, Bulkhead and a per-instance RateLimiter, and the ISBN client first takes a token from the shared Bucket4j quota quota:isbn. Every Bucket4j call to the shared Redis store goes through a Resilience4j CircuitBreaker and TimeLimiter so that a store outage fails open or closed deliberately

In the figure, orange is Bucket4j and blue is Resilience4j. The orange boxes are all about who may do how much; the blue ones are all about what happens when a dependency misbehaves, and that includes the Redis that holds the buckets.

The algorithms side by side

Resilience4j: fixed cycles

The Resilience4j RateLimiter (the default AtomicRateLimiter) splits time into cycles of limitRefreshPeriod, counted from the creation of the limiter. Each cycle starts with limitForPeriod permits; unused permits are lost at the end of the cycle, and a caller that finds none waits for the next cycle, but no longer than timeoutDuration. The defaults (50 permits every 500 ns, 5 s timeout) limit nothing, so both numbers must be set. The model is a fixed window: there is no gradual refill, and the counter never holds more than one cycle’s worth of permits.

Bucket4j: a token bucket

A Bucket4j bandwidth has a capacity C and a refill, for example refillGreedy(10, Duration.ofSeconds(1)), which adds tokens continuously at r = 10 per second. With nothing consumed since t0:

The capacity sets the largest burst and the refill sets the long-run rate, independently. A bucket can hold several bandwidths (100 per hour and 10 per second); a request must satisfy all of them. refillIntervally adds the whole refill at once at the end of each period, which behaves much like a fixed window, but not quite (see token bucket and bandwidths).

The burst at a cycle boundary

The two models differ most at the edge of a cycle. In any interval of length T, a cycle limiter can admit permits from every cycle that the interval touches, and an interval as short as one nanosecond can touch two cycles. A token bucket can admit at most its capacity plus what it refilled during the interval:

where L is limitForPeriod and P is limitRefreshPeriod. For "10 per second" (L = C = 10, P = 1 s, r = 10/s) the cycle limiter lets 20 requests through in the last millisecond of one cycle and the first of the next; the bucket lets 10 through, plus 0.01 token refilled in that millisecond. Over a whole second both bounds are 20, so the long-run rate is the same: the difference is how concentrated the burst can be, and with a token bucket you choose it through the capacity.

The test below fires ten requests at four instants against both libraries, configured as "10 per second" on the same manual clock, and a third limiter, a bucket with the same rate and a capacity of 2:

The same bursts against a cycle limiter and two token buckets
static final class ManualClock implements TimeMeter {          // Bucket4j's clock abstraction
    final AtomicLong nanos = new AtomicLong();
    @Override public long currentTimeNanos() { return nanos.get(); }
    @Override public boolean isWallClockBased() { return false; }
}

ManualClock r4jClock = new ManualClock();
RateLimiter cycle = RateLimiter.of("isbn", RateLimiterConfig.custom()
        .limitForPeriod(10)
        .limitRefreshPeriod(Duration.ofSeconds(1))
        .timeoutDuration(Duration.ZERO)                         // reject instead of waiting
        .build(), r4jClock::currentTimeNanos);                  // Resilience4j accepts a nanosecond supplier

ManualClock b4jClock = new ManualClock();
Bucket bucket = Bucket.builder()
        .addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofSeconds(1)))
        .withCustomTimePrecision(b4jClock)
        .build();

ManualClock smallClock = new ManualClock();
Bucket smallBurst = Bucket.builder()
        .addLimit(limit -> limit.capacity(2).refillGreedy(10, Duration.ofSeconds(1)))
        .withCustomTimePrecision(smallClock)
        .build();

// at 990, 1000, 1500 and 2000 ms: set each clock, then try ten times
// cycle      -> {990=10, 1000=10, 1500=0, 2000=10}
// bucket     -> {990=10, 1000=0,  1500=5, 2000=5}
// capacity 2 -> {990=2,  1000=0,  1500=2, 2000=2}

The results are the ones in the comments, and the timeline shows them:

timeline title Admitted out of ten requests at each instant section Resilience4j RateLimiter, cycles of 1 s 0.99 s : 10 admitted, last moment of cycle 0 1.00 s : 10 admitted, cycle 1 starts full 1.50 s : 0 admitted, cycle 1 is used up 2.00 s : 10 admitted, cycle 2 section Bucket4j bucket, capacity 10, greedy 10 per s 0.99 s : 10 admitted, the bucket is empty 1.00 s : 0 admitted, 0.1 token refilled 1.50 s : 5 admitted, 5.1 tokens refilled 2.00 s : 5 admitted, 5 tokens refilled
Figure 1. Ten requests at each instant, both configured as "10 per second"

Between 0.99 s and 1.00 s the cycle limiter admitted 20 requests in 10 milliseconds; the bucket admitted 10. The bucket with capacity 2 shows the other lever: the long-run rate is the same 10 per second, but no burst is ever larger than 2. With Resilience4j the only way to get a small burst is a short cycle (limitForPeriod: 1, limitRefreshPeriod: 100ms), which changes the granularity of everything else.

Feature comparison

Each row links the official documentation of both libraries. "Not built in" means the library does not provide it, not that it cannot be built on top.

Feature Resilience4j 2.4.0 Bucket4j 8.21.0

Algorithm and state

Algorithm

Fixed cycles: limitForPeriod per limitRefreshPeriod, no gradual refill, burst up to twice the limit at a boundary. RateLimiter

Token bucket: capacity plus greedy, intervally or aligned refill; the burst is the capacity. Refill types

Several limits per bucket

No: one limit per limiter; combine two limiters by decorating twice. RateLimiter

Yes: several bandwidths per bucket, each must allow the request (100 per hour and 10 per second). Bucket configuration

Per-key buckets

One limiter per name; one per client means one registry entry per client, kept forever. RateLimiter

One bucket per key of any type, created on first use, expiring in the store. ProxyManager

Distributed state and backends

Not built in: the state is in the JVM. RateLimiter

Redis (Lettuce, Jedis, Redisson, Vert.x, GLIDE), JDBC (PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, DB2), JCache, Hazelcast, Ignite, Infinispan, Coherence, Geode, MongoDB, Couchbase, Memcached, Cassandra, Caffeine. Distributed features

Consuming

Blocking and async waiting

Blocking: the caller is parked up to timeoutDuration. Non-blocking: reservePermission() returns the time to wait; decorators for CompletionStage. RateLimiter

Blocking: asBlocking().tryConsume(n, maxWait); scheduling: asScheduler(); async: AsyncBucketProxy returning CompletableFuture. Blocking API and Asynchronous API

Weighted consumption

executeSupplier(permits, …​) and acquirePermission(permits); the permits attribute of @RateLimiter is ignored by the Spring aspect in 2.4.0. RateLimiter

tryConsume(n) and every other method take a token count (the search endpoint costs 5). Bucket

Returning tokens

Not built in; drainPermissionsOnResult does the opposite and empties the cycle. RateLimiter

addTokens (capped at capacity) and forceAddTokens. Returning tokens

Changing limits at runtime

changeLimitForPeriod and changeTimeoutDuration, in this JVM only; the cycle length is fixed. RateLimiter

replaceConfiguration with a TokensInheritanceStrategy, or implicit replacement by version for stored buckets. Configuration replacement and Implicit configuration replacement

Other patterns (Resilience4j only)

Circuit breaker

CircuitBreaker. CircuitBreaker

Not built in. Basic features

Retry

Retry. Retry

Not built in; the compare-and-swap retries of a ProxyManager (maxRetries, retryStrategy) are internal. Max retries for CAS operations

Bulkhead

Bulkhead and ThreadPoolBulkhead. Bulkhead

Not built in. Basic features

Time limiter

TimeLimiter. TimeLimiter

Not built in; requestTimeout bounds one store call. Request timeout

Cache

Cache over JCache. Cache

Not built in. Basic features

Spring integration

Annotations

@RateLimiter, @CircuitBreaker, @Retry, @Bulkhead, @TimeLimiter. Spring Boot

None in Bucket4j. The community starter adds @RateLimiting and @IgnoreRateLimiting. Bucket4j README: Spring Boot starter

Spring Boot auto-configuration

resilience4j-spring-boot4 binds resilience4j.* properties. Spring Boot

None in Bucket4j; the community starter binds bucket4j.* (filters, rate limits, bandwidths). starter: properties

Actuator endpoints

ratelimiters, ratelimiterevents and one pair per pattern. Spring Boot

None in Bucket4j; the starter exposes a bucket4j endpoint. starter: monitoring

Health indicators

rateLimiters and circuitBreakers health components, opt-in per instance. Spring Boot

None; write a HealthIndicator for the store yourself. Distributed checklist

Micrometer metrics

Gauges resilience4j.ratelimiter.available.permissions and waiting_threads, plus meters for every pattern. Micrometer

None built in; a BucketListener feeds your own meters (the starter adds bucket4j_summary_* counters). Micrometer integration example

Ecosystem

Reactor and Kotlin

resilience4j-reactor operators, resilience4j-kotlin for coroutines and Flow, RxJava 2 and 3. Reactor and Kotlin

No Reactor or Kotlin module; wrap the CompletableFuture of the async API (Mono.fromFuture). Asynchronous API

Spring Cloud Gateway integration

Through Spring Cloud CircuitBreaker: the gateway CircuitBreaker filter. No rate limiter. CircuitBreaker filter

Web MVC Bucket4jFilterFunctions.rateLimit and the WebFlux Bucket4j limiter of RequestRateLimiter. Web MVC rate limiter

Licence

Apache-2.0. Repository

Apache-2.0. Repository

Java baseline

Java 17 since 2.0.0. Getting started

Java 17 for bucket4j_jdk17-; bucket4j_jdk11- stopped at 8.15.0, open-source Java 8 builds at 8.10.1. Adding the dependency

Latest version (5 October 2026)

2.4.0. Release

8.21.0 (2 October 2026). Release notes

Two rows deserve a comment. Health: a Resilience4j limiter that is doing its job reports RATE_LIMITED, which is a signal, not a failure; a Bucket4j store that is down is a real failure of a dependency, which is why Bucket4j observability writes a HealthIndicator for it. Several limits: two Resilience4j limiters stacked on one call work, but a call admitted by the first and rejected by the second has spent a permit of the first; a Bucket4j bucket checks all its bandwidths atomically and consumes from none of them when one refuses.

Decision guide

flowchart TD Q{What must be limited?} Q -->|calls this instance makes
to one dependency| R4J["Resilience4j RateLimiter
per instance"] Q -->|one quota of a third party,
shared by every replica| SHARED["Bucket4j distributed bucket
ProxyManager, key quota:isbn"] Q -->|requests of your clients,
per API key, user or IP| WHERE{Where is the decision made?} WHERE -->|in the service: plans,
costs per endpoint| INBOUND["Bucket4j in the service
starter or your own filter"] WHERE -->|at the edge, before
the request reaches a service| EDGE["Gateway Bucket4j filter
or an API manager"] Q -->|how many calls run
at the same time| CONC["Bulkhead or
Spring Framework 7 ConcurrencyLimit"] Q -->|failures and slowness
of a dependency| FT["Resilience4j CircuitBreaker,
Retry, TimeLimiter"] classDef b4j fill:#fff1e6,stroke:#b34700,color:#222 classDef r4j fill:#e8f0f8,stroke:#1c4b73,color:#222 class SHARED,INBOUND,EDGE b4j class R4J,CONC,FT r4j
Figure 2. Choosing a limiter, or something else
Need Use Why

Stay under a dependency’s limit from one instance (the ISBN provider allows 5 calls per second per client)

Resilience4j RateLimiter

In memory, no network call, composes with Retry and CircuitBreaker in one decorator chain. The limit multiplies with the replica count, so give each instance its share.

One quota of a third-party API shared by every replica (1 000 ISBN lookups per day)

Bucket4j distributed bucket

Only shared state holds a service-wide limit; the per-instance limiter admits the quota once per replica.

Inbound per-client quotas (free and pro plans)

the starter for YAML-driven limits, or your own filter for full control

Per-key buckets, several bandwidths, weighted costs and a shared store are what client quotas need. The starter is a community project; the manual filter has no third-party dependency.

Limits at the edge, before traffic reaches a service

the gateway (Bucket4j or the Redis RequestRateLimiter)

Stops abusive traffic once, for every service behind the gateway. Gateway-level quotas, plans and API managers are the subject of a separate issue (#184).

Per-user token quotas for an LLM endpoint (each call costs the tokens it used, across every replica)

a Bucket4j distributed bucket per user, consuming a weighted cost per call

The provider’s own limit is per account, not per user; the per-instance limiter and @ConcurrencyLimit on Spring AI in production keep one instance under it, a per-user bucket keeps one user from spending everyone’s quota.

Limit concurrency, not rate

Bulkhead or Spring Framework 7 @ConcurrencyLimit

A rate limiter allows 10 calls per second even if each takes a minute; a bulkhead caps how many run at once.

The guide has one deliberately absent row: "inbound limits with Resilience4j". It works for a whole endpoint on one instance (protect /books/search from a burst), and the RateLimiter page shows how. For anything per client it is the wrong tool, because each client would need its own limiter instance in an unbounded registry, and the limit would still multiply with the replica count.

Using both together

In the Bookshelf the two libraries meet in three places.

Bucket4j inbound, Resilience4j outbound

The inbound API is protected by Bucket4j: a bucket per API key in Redis, so the free plan’s 100 requests per hour hold across every replica. The outbound calls (ISBN provider, pricing-service) are decorated with Resilience4j, as in the rest of this section. Nothing in either library knows about the other at this level; they are simply in different filters.

A CircuitBreaker and TimeLimiter around the ProxyManager

A Bucket4j bucket in Redis turns every inbound request into an outbound call to Redis. When Redis hangs, a request waits; when it is down, a request fails. Bucket4j’s own requestTimeout bounds one call, but it does not remember that the last hundred calls failed. Resilience4j does: a TimeLimiter bounds the wait, and a CircuitBreaker stops calling a store that is failing, so that after a few slow requests the rest get an answer at once. What the answer is (let the request through without a limit, or refuse it) is a policy, fail open or fail closed, and it belongs in your code, as described in fallbacks and composition.

RateLimitStoreConfig.java: the free plan and an asynchronous ProxyManager
@Configuration(proxyBeanMethods = false)
class RateLimitStoreConfig {

    /** The free plan of the Bookshelf API: 100 requests per hour and at most 10 per second. */
    @Bean
    BucketConfiguration freePlan() {
        return BucketConfiguration.builder()
                .addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofHours(1)).id("hour"))
                .addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofSeconds(1)).id("second"))
                .build();
    }

    @Bean(destroyMethod = "shutdown")
    RedisClient bucket4jRedisClient(DataRedisProperties redis) {   // Boot does not create a RedisClient bean
        return RedisClient.create(RedisURI.builder().withHost(redis.getHost()).withPort(redis.getPort()).build());
    }

    @Bean
    AsyncProxyManager<String> buckets(RedisClient bucket4jRedisClient) {
        return Bucket4jLettuce.casBasedBuilder(bucket4jRedisClient)
                .expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(10)))
                .requestTimeout(Duration.ofSeconds(1))           // last line of defence; the TimeLimiter fires first
                .build()
                .withMapper((String key) -> key.getBytes(StandardCharsets.UTF_8))
                .asAsync();
    }

    @Bean(destroyMethod = "shutdown")
    ScheduledExecutorService rateLimitTimeouts() {
        return Executors.newScheduledThreadPool(2);
    }
}

The asynchronous proxy is what makes the TimeLimiter useful: it returns a CompletableFuture, which the TimeLimiter can complete with a TimeoutException and cancel. Around it, the CircuitBreaker records timeouts and Redis errors as failures, while a refused consumption (a 429) is a normal result and never opens the breaker.

GuardedRateLimiter.java: Bucket4j decides, Resilience4j guards the store
@Component
public class GuardedRateLimiter {

    public enum FailureMode { OPEN, CLOSED }

    private final AsyncProxyManager<String> buckets;
    private final BucketConfiguration freePlan;
    private final CircuitBreaker breaker;
    private final TimeLimiter timeLimiter;
    private final ScheduledExecutorService scheduler;
    private final FailureMode failureMode;

    GuardedRateLimiter(AsyncProxyManager<String> buckets, BucketConfiguration freePlan,
                       CircuitBreakerRegistry breakers, TimeLimiterRegistry timeLimiters,
                       ScheduledExecutorService rateLimitTimeouts,
                       @Value("${bookshelf.rate-limit.on-store-failure:open}") FailureMode failureMode) {
        this.buckets = buckets;
        this.freePlan = freePlan;
        this.breaker = breakers.circuitBreaker("rate-limit-store");
        this.timeLimiter = timeLimiters.timeLimiter("rate-limit-store");
        this.scheduler = rateLimitTimeouts;
        this.failureMode = failureMode;
    }

    public Decision check(String apiKey, long tokens) {
        Supplier<CompletableFuture<ConsumptionProbe>> consume = () -> buckets.builder()
                .build("api:" + apiKey, () -> CompletableFuture.completedFuture(freePlan))
                .tryConsumeAndReturnRemaining(tokens);
        try {
            ConsumptionProbe probe = breaker
                    .executeCompletionStage(() -> timeLimiter.executeCompletionStage(scheduler, consume))
                    .toCompletableFuture()
                    .join();
            return probe.isConsumed()
                    ? Decision.allowed(probe.getRemainingTokens())
                    : Decision.rejected(probe.getNanosToWaitForRefill());
        } catch (RuntimeException storeFailure) {      // TimeoutException, CallNotPermittedException, Redis errors
            log.warn("Rate-limit store unavailable ({}), failing {}", rootCause(storeFailure), failureMode);
            return failureMode == FailureMode.OPEN ? Decision.unchecked() : Decision.unavailable();
        }
    }
}

Decision.rejected turns the nanoseconds into whole seconds, rounded up, for Retry-After. A servlet filter calls check for every /api/** request and maps the four outcomes to responses:

RateLimitFilter.java: the four outcomes
Decision decision = limiter.check(apiKey, 1);
switch (decision.outcome()) {
    case ALLOWED -> {
        response.setHeader("X-Rate-Limit-Remaining", Long.toString(decision.remaining()));
        chain.doFilter(request, response);
    }
    case UNCHECKED -> chain.doFilter(request, response);                       // fail open
    case REJECTED -> {
        response.setHeader("Retry-After", Long.toString(decision.retryAfterSeconds()));
        problem(response, HttpStatus.TOO_MANY_REQUESTS, "Rate limit of your plan exceeded");
    }
    case UNAVAILABLE -> {                                                       // fail closed
        response.setHeader("Retry-After", Long.toString(decision.retryAfterSeconds()));
        problem(response, HttpStatus.SERVICE_UNAVAILABLE, "Rate limiting is temporarily unavailable");
    }
}

Source: Bucket4j reference: rate limiting a REST API and Spring Framework: Error Responses. problem(…​) writes an RFC 9457 ProblemDetail as application/problem+json; the complete filter, with key resolution and the order relative to Spring Security, is on manual Spring Boot integration.

application.yml: the guard of the store
bookshelf:
  rate-limit:
    on-store-failure: open          # or closed
resilience4j:
  timelimiter:
    instances:
      rate-limit-store:
        timeout-duration: 200ms
        cancel-running-future: true
  circuitbreaker:
    instances:
      rate-limit-store:
        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 window is tiny on purpose, so that the behaviour is visible in a test; in production use a time-based window of tens of seconds. Pausing the Redis container (a hung server, which a refused connection does not reproduce) and sending six requests gave:

Table 1. Observed while Redis was paused
Request Fail closed Fail open

First ones

503 after 214 to 217 ms (the 200 ms TimeLimiter), Retry-After: 5, application/problem+json

200 after 204 to 218 ms, without X-Rate-Limit-Remaining

After the breaker opened

503 after 2 to 4 ms

200 after 3 to 5 ms

After Redis came back and 2 s passed

200 with X-Rate-Limit-Remaining: 9, breaker CLOSED again

Not measured in this run; the breaker is configured identically

How many slow requests it takes depends on what is already in the window: in the fail-closed run the four-call window already held two successful calls, so two timeouts reached the 50 % threshold; in the fail-open run it held one, and three timeouts were needed. Either way the slow phase is short and bounded, which is the point. A rejected consumption (429) counts as a success for the breaker, so a client hammering its quota never opens it.

Which policy to choose is the same decision as for any fallback: fail open for limits that protect cost or fairness and where a few minutes without limits is cheaper than an outage, fail closed for limits that protect security (login attempts, OTP) or money (a paid third-party quota). The trade-offs, and a third option that falls back to a local bucket per instance, are on manual Spring Boot integration; how to alert on a fail-open period is on Bucket4j observability.

Retry honouring Bucket4j’s Retry-After

The third meeting point is a client of a Bucket4j-limited API, for example a partner of the Bookshelf or one Bookshelf service calling another. Bucket4j’s 429 carries Retry-After, and a Resilience4j Retry with an IntervalBiFunction can read it, so the retry waits exactly as long as the server asked instead of guessing:

RetryAfterIntervalFunction.java: wait at least what the server asked
public class RetryAfterIntervalFunction implements IntervalBiFunction<Object> {

    static final long MAX_WAIT_MILLIS = 10_000;

    private final IntervalFunction backoff = IntervalFunction.ofExponentialRandomBackoff(
            Duration.ofMillis(100), 2.0, 0.5, Duration.ofSeconds(2));

    @Override
    public Long apply(Integer attempt, Either<Throwable, Object> outcome) {
        long computed = backoff.apply(attempt);
        if (outcome.isLeft() && outcome.getLeft() instanceof HttpStatusCodeException e) {
            HttpHeaders headers = e.getResponseHeaders();
            String value = headers == null ? null : headers.getFirst(HttpHeaders.RETRY_AFTER);
            long asked = value == null ? 0 : Long.parseLong(value.trim()) * 1000;   // delta-seconds
            if (asked > MAX_WAIT_MILLIS) {
                return -1L;                                 // too long: stop retrying
            }
            return Math.max(computed, asked);
        }
        return computed;
    }
}
application.yml: the client’s retry
resilience4j:
  retry:
    instances:
      bookshelf-api:
        max-attempts: 3
        retry-exceptions:
          - org.springframework.web.client.HttpClientErrorException$TooManyRequests
        interval-bi-function: compare.RetryAfterIntervalFunction

This version reads only the delta-seconds form, which is what every Bucket4j example in this section sends; the version on Retry also parses the HTTP-date form, which a third-party API may use. With the bucket of a key exhausted by ten requests, a client call through this retry got 429 with Retry-After: 1, waited, and succeeded after 1 044 ms in total, with one retry. Two conditions keep this honest: the retry must stop when the server asks for longer than the caller can wait (the -1), and only TooManyRequests is retried, never a 4xx that will not change.

The Bookshelf with both libraries

Putting the pieces together, the Bookshelf runs:

  • at the edge: the gateway’s Bucket4j filter with a coarse per-client limit, see Bucket4j in Spring Cloud Gateway

  • inbound in each replica: Spring Security, then the Bucket4j limiter above, with the free plan (100 per hour and 10 per second), the pro plan (5 000 per hour and 50 per second) and internal clients unlimited, buckets in Redis, search costing 5 tokens

  • the Redis calls of that limiter: a Resilience4j TimeLimiter of 200 ms and a CircuitBreaker, failing open for the API plans

  • outbound to the ISBN provider: one shared Bucket4j bucket quota:isbn for the daily quota (failing closed, because the quota costs money), then the Resilience4j chain of the section: Retry honouring Retry-After, CircuitBreaker, TimeLimiter, with a per-instance RateLimiter for the provider’s per-second limit

  • outbound to pricing-service: Resilience4j only, as in fallbacks and composition

The free plan, seen from a client. Ten requests in one burst pass with the remaining count going from 9 to 0; the eleventh is refused for about 100 ms, the time the 10-per-second bandwidth needs for one token, which Retry-After rounds up to a whole second:

curl -si -H 'X-API-Key: key-free-demo' http://localhost:8080/api/books   # the 11th request within 100 ms
HTTP/1.1 429
Retry-After: 1
Content-Type: application/problem+json
Content-Length: 86

{"detail":"Rate limit of your plan exceeded","status":429,"title":"Too Many Requests"}

And the same request while Redis is paused, in fail-closed mode:

HTTP/1.1 503
Retry-After: 5
Content-Type: application/problem+json
Content-Length: 96

{"detail":"Rate limiting is temporarily unavailable","status":503,"title":"Service Unavailable"}

Source: Bucket4j reference: rate limiting a REST API and RFC 9457: Problem Details for HTTP APIs. The responses were captured from the scratch application on Spring Boot 4.1.1 with Redis 7. To send the burst from a shell, put the eleven URLs in one curl invocation: separate curl processes take long enough each that the 10-per-second bandwidth refills between them.

The same idea in other stacks

Other stacks on this site have the same split between an in-process limiter, a shared-store limiter and the fault-tolerance patterns.

Stack and library How it compares Where it is covered

ASP.NET Core rate limiting middleware (AddRateLimiter)

Fixed-window, sliding-window, token-bucket and concurrency limiters, partitioned by a key such as the user name, with a configurable rejection status. Like Bucket4j it has per-key token buckets; like Resilience4j it keeps the state in the process. Outbound fault tolerance is Polly and Microsoft.Extensions.Http.Resilience.

Performance and caching: rate limiting and HTTP client and resilience

NestJS @nestjs/throttler

A counter per tracker (by default the client IP) that expires after ttl, with named limits and @Throttle per handler. In memory by default; a shared ThrottlerStorage (for example on Redis) makes it hold across instances. The fault-tolerance patterns are a separate package.

Rate limiting and encryption and Resilience, idempotency and locks

Quarkus, SmallRye Fault Tolerance @RateLimit

value invocations per window, with an optional minSpacing, rejecting with RateLimitException. Per method and per instance, in the same annotation family as @CircuitBreaker and @Retry: the closest match to the Resilience4j RateLimiter.

Fault tolerance and resilience: @RateLimit

Redis Lua token bucket

The algorithm Bucket4j implements, written by hand: one Lua script reads the level, computes the refill and writes it back atomically. Shared across instances; you own clock handling, expiry and every edge case that Bucket4j’s backends already handle.

Redis use cases and patterns: rate limiting

Spring Cloud Gateway RequestRateLimiter (Redis)

A token bucket in a Redis Lua script per key (replenishRate, burstCapacity, requestedTokens), at the edge. It fails open when Redis is unreachable, while the gateway’s Bucket4j limiter fails closed.

Gateway and OpenFeign and Bucket4j in Spring Cloud Gateway

The broader mapping of the fault-tolerance patterns across stacks is in resilience fundamentals, and gateway-level limiting in a wider architecture in API gateway and BFF.

Pitfalls

Pitfall What happens Fix

Per-client API limits with the Resilience4j RateLimiter

One limiter per client in an unbounded registry, and the limit multiplies with replicas

Bucket4j buckets per key in a shared store

A Resilience4j RateLimiter for a third-party quota shared by all replicas

Three replicas call the provider three times as often as the quota allows

A Bucket4j distributed bucket for the quota, a Resilience4j limiter only for the per-instance share

"10 per second" with Resilience4j where bursts matter

20 requests can pass within a millisecond at a cycle boundary

A token bucket with a small capacity, or a short limitRefreshPeriod

Bucket4j where a circuit breaker or retry is needed

Bucket4j limits how often, not what happens when the call fails

Resilience4j for the outbound call; the two compose

A Bucket4j store call without a time bound

A hung Redis makes every request wait for the client’s timeout

requestTimeout on the ProxyManager plus a TimeLimiter, or both

No explicit policy for a store outage

The exception becomes a 500, which is neither open nor closed

A CircuitBreaker and an explicit fail-open or fail-closed decision per limit

Counting 429 responses as breaker failures

A client exhausting its quota opens the breaker for everybody

Only store errors and timeouts are failures; a refused consumption is a result

A client retrying a 429 on its own schedule

Retries arrive before the bucket has refilled and earn more 429s

A Resilience4j Retry whose interval honours Retry-After, with a maximum

Stacking two Resilience4j limiters for "per hour and per second"

A call refused by the second has already spent a permit of the first

One Bucket4j bucket with two bandwidths, checked atomically

Relying on @RateLimiter(permits = 5) for weighted calls

The Spring aspect in 2.4.0 ignores permits and takes one

executeSupplier(5, …​) in Resilience4j, or tryConsume(5) in Bucket4j

What changed

  • Resilience4j 2.4.0 (the current release) added resilience4j-spring-boot4; the RateLimiter model is unchanged since 2.x. 2.3.0 added the permits attribute to @RateLimiter, which the Spring 6 aspect does not read (see RateLimiter). Spring Cloud 2025.1.3 still manages Resilience4j at 2.3.0, so import resilience4j-bom before spring-cloud-dependencies.

  • Bucket4j 8.21.0 (2 October 2026) added bucket4j_jdk17-bom, new backends (Cassandra, Memcached, Ignite 3, Infinispan 15, Coherence 21) and the compare-and-swap retry settings; the builder API of 8.5.0 (addLimit(limit → …​)) replaced Bandwidth.simple and Refill, and the artifacts are bucket4j_jdk17-* since the 8.11 line.

  • Spring Cloud Gateway gained Bucket4j limiters on both servers (the WebFlux one announced with Spring Cloud 2025.0), next to the Redis RequestRateLimiter; none of them is Resilience4j.

  • Spring Framework 7 added @ConcurrencyLimit and @Retryable in the core framework, but no rate limiter, so neither library is replaced: see Spring Framework native resilience.

  • The community starter has no GA for Spring Boot 4.1 yet (0.20.0-RC1); 0.14.0 targets Spring Boot 4.0. See the starter page.

  • The places where the Bucket4j reference and the 8.21.0 code disagree are collected on migration and what’s changed.