Bucket4j: Testing Rate Limits

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

A rate limiter is a piece of code whose behaviour depends on time, on shared state and on the failure of a remote store, and each of those is awkward to test: a test that sleeps for a minute is slow, a test that races real threads against real refills is flaky, and a test that only checks the happy path says nothing about the day Redis stops answering. This page shows how to test the Bookshelf limiter of the manual integration and the YAML of the community starter at four levels, from a plain JUnit test with a clock the test controls to an outage injected into a real Redis.

It was written against Bucket4j 8.21.x, Spring Boot 4.1.x, Spring Framework 7.0.x, Java 21, bucket4j-spring-boot-starter 0.20.0-RC1, Testcontainers 2.0.x and Resilience4j 2.4.0, and verified on 5 October 2026. Every test on this page was compiled and run on Boot 4.1.1 with JDK 21 in a scratch project, and all 24 passed: 14 tests that need no Docker, 5 integration tests that start Redis, PostgreSQL and Toxiproxy containers (none was skipped), and 5 tests of the starter. The application classes under test (RateLimitFilter, RateLimitService, RateLimitProperties, the exception handler and the StoreConfig beans) are the ones of the manual integration page and are not repeated here. General Spring Boot testing is on unit and integration testing, Testcontainers itself on integration tests with Testcontainers, and the same techniques for Resilience4j on testing resilience.

The test topology

Use the lowest level that can show the behaviour you care about; every level up costs seconds and adds a way to fail that is not a bug in the limiter.

flowchart TB subgraph L1["Level 1: plain JUnit, milliseconds, no Docker"] T1["TimeMeterTest
ManualClock"] T2["NoOverConsumptionTest
64 threads, one key"] T3["ImplicitReplacementTest
configuration versions"] end subgraph L2["Level 2: Spring context, local Caffeine store, no Docker"] M["FilterMockMvcTest
MockMvcTester"] W["ReactiveWebTestClientTest
WebTestClient"] P["TinyProfileTest
profile tiny, real HTTP"] Y["StarterYamlTest
starter YAML, real HTTP"] end subgraph L3["Level 3: Testcontainers"] R["RedisProxyManagerIT"] G["PostgresProxyManagerIT
Flyway + ServiceConnection"] end subgraph L4["Level 4: Toxiproxy between the app and Redis"] A["Spring Boot app
RateLimitService + CircuitBreaker"] --> X["Toxiproxy"] --> D[("Redis")] end L1 --> L2 --> L3 --> L4

The levels also differ in what they prove. Level 1 proves the arithmetic of the bucket and the configuration. Level 2 proves that the HTTP layer turns a probe into the right status, headers and body. Level 3 proves that the state really is shared and atomic in the real store. Level 4 proves what the service does when the store misbehaves, which no mock can: a stalled connection looks very different from a refused one.

Table 1. The tests of this page
Test class Proves Level Docker

TimeMeterTest (4)

Refill, Retry-After and clock rules, with no sleeping

1

No

NoOverConsumptionTest (1)

64 threads never get more than the capacity

1

No

ImplicitReplacementTest (3)

A new configuration version replaces the stored one once

1

No

FilterMockMvcTest (2)

429, Retry-After, X-Rate-Limit-Remaining, ProblemDetail

2

No

ReactiveWebTestClientTest (1)

The same contract on the WebFilter

2

No

TinyProfileTest (3)

The tiny profile, and exactly 50 of 200 parallel requests

2

No

RedisProxyManagerIT (2)

Two replicas share one Redis bucket, no over-consumption

3

Yes

PostgresProxyManagerIT (1)

The whole application over PostgreSQL and Flyway

3

Yes

RedisOutageFailClosedIT and RedisOutageFailOpenIT (1 each)

503 and recovery, or 200 without counting

4

Yes

StarterYamlTest (2), StarterMockMvcPitfallTest (1), InvalidYamlTest (2)

The starter’s YAML limits, the MockMvc trap, startup validation

2

No

Classes named *Test run in Maven Surefire with mvn test; classes named *IT need Docker and run in Failsafe with mvn verify. The split keeps the fast feedback loop free of containers. Failsafe must be declared in the build (see the <build> snippet below): Spring Boot’s parent manages its version but does not bind it, so without the declaration mvn verify runs no *IT class and still passes.

Dependencies

On Spring Boot 4 the test starters are split by web stack, and Testcontainers 2 renamed its modules. Boot manages the versions of everything below except toxiproxy-java and the Bucket4j artifacts, which come from bucket4j_jdk17-bom.

pom.xml: test dependencies
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-testcontainers</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-junit-jupiter</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-postgresql</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.testcontainers</groupId><artifactId>testcontainers-toxiproxy</artifactId><scope>test</scope></dependency>
<dependency><groupId>eu.rekawek.toxiproxy</groupId><artifactId>toxiproxy-java</artifactId><version>2.1.11</version><scope>test</scope></dependency>
pom.xml: bind Failsafe so that mvn verify runs the *IT classes
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-failsafe-plugin</artifactId>   <!-- version and the integration-test/verify goals come from the Boot parent -->
        </plugin>
    </plugins>
</build>

Level 1: deterministic time with a custom TimeMeter

A bucket reads time from a TimeMeter. Replace it with a clock the test advances by hand and refill becomes arithmetic: no Thread.sleep, no flakiness, and a test that covers an hour of refill runs in a millisecond. A TimeMeter has two abstract methods, currentTimeNanos() and isWallClockBased(); the reference’s custom-clock example implements only the first and does not compile against 8.21.0. The clock below returns true from isWallClockBased() because it is also handed to a ProxyManager in a later test, and a manager refuses a clock that is not wall-clock based.

ManualClock.java
/** A clock the test moves by hand. Wall-clock based, so it is accepted by local buckets and by ProxyManager.clientClock. */
final class ManualClock implements TimeMeter {

    private final AtomicLong nanos = new AtomicLong(1_800_000_000_000_000_000L);   // an arbitrary fixed instant

    void advance(Duration duration) {
        nanos.addAndGet(duration.toNanos());
    }

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

    @Override
    public boolean isWallClockBased() {
        return true;
    }
}

There are two places to put the clock. A local bucket takes it with withCustomTimePrecision(clock) on its builder; a ProxyManager takes it with clientClock(clock) on its builder, and every proxy it creates then uses that clock. The first two tests below use the first, the third the second, and the fourth documents the rule that decides which clocks a manager accepts.

TimeMeterTest.java: refill, Retry-After and the clock rules
/** Deterministic refill: no sleeping, no flakiness. */
class TimeMeterTest {

    private final ManualClock clock = new ManualClock();

    @Test
    void greedyRefillIsExactWithoutSleeping() {
        Bucket bucket = Bucket.builder()
                .addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofMinutes(1)))
                .withCustomTimePrecision(clock)
                .build();

        assertThat(bucket.tryConsume(10)).isTrue();
        var rejected = bucket.tryConsumeAndReturnRemaining(1);
        assertThat(rejected.isConsumed()).isFalse();
        assertThat(Duration.ofNanos(rejected.getNanosToWaitForRefill())).isEqualTo(Duration.ofSeconds(6));

        clock.advance(Duration.ofSeconds(30));
        assertThat(bucket.getAvailableTokens()).isEqualTo(5);

        clock.advance(Duration.ofHours(1));                       // refill never exceeds the capacity
        assertThat(bucket.getAvailableTokens()).isEqualTo(10);
    }

    @Test
    void intervallyRefillWaitsForTheWholePeriod() {
        Bucket bucket = Bucket.builder()
                .addLimit(limit -> limit.capacity(10).refillIntervally(10, Duration.ofMinutes(1)))
                .withCustomTimePrecision(clock)
                .build();
        bucket.tryConsume(10);

        clock.advance(Duration.ofSeconds(59));
        assertThat(bucket.getAvailableTokens()).isZero();         // nothing until the period is complete
        clock.advance(Duration.ofSeconds(1));
        assertThat(bucket.getAvailableTokens()).isEqualTo(10);    // all ten at once
    }

    @Test
    void theServiceReportsRetryAfterFromTheManualClock() {
        ProxyManager<String> proxyManager = Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder())
                .clientClock(clock)                               // the clock of the ProxyManager, not of one bucket
                .build();
        var plans = Map.of("free", new PlanLimit(20, Duration.ofMinutes(1), Refill.GREEDY));
        var properties = new RateLimitProperties("free", plans, Map.of(), Map.of(), Map.of(), FailureMode.CLOSED, null);
        var service = new RateLimitService(proxyManager, properties, CircuitBreakerRegistry.ofDefaults());

        for (int i = 0; i < 20; i++) {
            assertThat(service.check("key:a", "free", 1).allowed()).isTrue();
        }
        assertThat(service.check("key:a", "free", 1).retryAfterSeconds()).isEqualTo(3);   // 60 s / 20 tokens

        clock.advance(Duration.ofSeconds(3));
        var next = service.check("key:a", "free", 1);
        assertThat(next.allowed()).isTrue();
        assertThat(next.remaining()).isZero();
    }

    @Test
    void aClockThatIsNotWallClockBasedIsRefusedByAProxyManager() {
        TimeMeter monotonic = new TimeMeter() {
            @Override public long currentTimeNanos() { return 0; }
            @Override public boolean isWallClockBased() { return false; }
        };
        var builder = Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder());
        assertThatThrownBy(() -> builder.clientClock(monotonic)).isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("not wall-based clock");
    }
}

The numbers are exact because the clock is. A greedy bucket of ten tokens per minute that is emptied reports exactly six seconds to wait, half a minute later it holds exactly five tokens, and an hour later it holds ten, never more than the capacity. An intervally refilled bucket holds nothing until the 60th second and then all ten at once, which is the difference between the two refill types made visible without waiting for either. The third test checks the whole service, not only the bucket: after 20 requests the decision says to retry in 3 seconds (60 s divided by 20 tokens), and after the clock advances three seconds one more request is allowed with zero tokens remaining. The fourth shows what the manager does with a non-wall clock: IllegalArgumentException, with a message that says so, at build time.

Level 1: no over-consumption under concurrency

The property a limiter must never violate is that it admits no more than the capacity, however the requests interleave. The test below freezes the clock, so that nothing refills while threads run, and lets 64 threads make five attempts each against a bucket of 100 tokens: 320 attempts, of which exactly 100 may succeed. Each proxy is built per call, as the filter does, which also shows that the state lives in the ProxyManager and not in the proxy object.

NoOverConsumptionTest.java: many threads, one key
/** Many threads, one key: exactly the capacity is granted, and every grant saw a different remaining-token count. */
class NoOverConsumptionTest {

    static final BucketConfiguration HUNDRED = BucketConfiguration.builder()
            .addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofMinutes(1)))
            .build();

    @Test
    void sixtyFourThreadsNeverConsumeMoreThanTheCapacity() throws Exception {
        ProxyManager<String> proxyManager = Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder())
                .clientClock(new ManualClock())                           // frozen: nothing refills while the threads run
                .build();

        int threads = 64;
        int attemptsPerThread = 5;                                        // 320 attempts for 100 tokens
        AtomicInteger granted = new AtomicInteger();
        Set<Long> remainingSeen = ConcurrentHashMap.newKeySet();
        CountDownLatch go = new CountDownLatch(1);
        List<Future<?>> futures = new ArrayList<>();
        try (var pool = Executors.newFixedThreadPool(threads)) {
            for (int t = 0; t < threads; t++) {
                futures.add(pool.submit(() -> {
                    go.await();
                    for (int i = 0; i < attemptsPerThread; i++) {
                        // a new proxy per call is how the filter works: the state lives in the ProxyManager, not in the proxy
                        var probe = proxyManager.builder().build("key:shared", () -> HUNDRED).tryConsumeAndReturnRemaining(1);
                        if (probe.isConsumed()) {
                            granted.incrementAndGet();
                            remainingSeen.add(probe.getRemainingTokens());
                        }
                    }
                    return null;
                }));
            }
            go.countDown();
            for (Future<?> future : futures) {
                future.get();
            }
        }

        assertThat(granted.get()).isEqualTo(100);
        assertThat(remainingSeen).hasSize(100);                           // 99, 98, ... 0, each observed exactly once
    }
}

Two assertions make the test strict. The count of grants must be exactly 100, not at most 100, because an implementation that loses updates would pass an upper bound while silently denying legitimate requests. And the set of remaining-token values reported to the winners must contain 100 distinct values (99 down to 0): two threads that both saw the same remaining count would mean that two consumptions were applied to one state. The latch releases all threads at the same instant, which is what turns a sequential loop into a race. The same test against Redis, with two managers standing for two replicas, is in the Testcontainers section below.

Level 2: HTTP assertions with MockMvc

The servlet filter of the manual integration is a Spring bean registered with a FilterRegistrationBean, so MockMvc runs it: the test goes through the same OncePerRequestFilter, the same @RestControllerAdvice and the same ProblemDetail as a real request, without a server. To control time, the test configuration replaces the production ProxyManager<String> with one that uses the ManualClock; @Primary makes it win over the application’s bean.

MockMvcTester, the AssertJ-based entry point of Spring Framework (6.2 and later), reads better than the classic MockMvc with matchers and is auto-configured by @AutoConfigureMockMvc, whose package in Boot 4 is org.springframework.boot.webmvc.test.autoconfigure.

FilterMockMvcTest.java: status, Retry-After, remaining tokens and the clock
/** The servlet filter of the manual integration, called through MockMvc, with a clock the test controls. */
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("manual")
@Import(FilterMockMvcTest.ClockConfig.class)
class FilterMockMvcTest {

    @TestConfiguration(proxyBeanMethods = false)
    static class ClockConfig {
        @Bean
        ManualClock manualClock() {
            return new ManualClock();
        }

        @Bean
        @Primary                                    // wins over the production ProxyManager<String>
        ProxyManager<String> testProxyManager(ManualClock clock) {
            return Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder()).clientClock(clock).build();
        }
    }

    @Autowired MockMvcTester mvc;
    @Autowired ManualClock clock;
    @Autowired ProxyManager<String> proxyManager;

    @BeforeEach
    void freshBuckets() {                                           // the context is shared by the two tests
        proxyManager.removeProxy("key:" + ClientIdentifier.fingerprint("key-free-demo"));
        proxyManager.removeProxy("key:" + ClientIdentifier.fingerprint("key-pro-demo"));
    }

    @Test
    void twentyRequestsThenA429WithRetryAfterThenTheClockMoves() {
        for (int i = 0; i < 20; i++) {
            assertThat(mvc.get().uri("/books").header("X-API-Key", "key-free-demo"))
                    .hasStatusOk()
                    .headers().hasValue("X-Rate-Limit-Remaining", String.valueOf(19 - i));
        }

        assertThat(mvc.get().uri("/books").header("X-API-Key", "key-free-demo"))
                .hasStatus(429)
                .headers()
                .hasValue("Retry-After", "3")
                .hasValue("X-Rate-Limit-Remaining", "0")
                .hasValue("Content-Type", "application/problem+json");

        assertThat(mvc.get().uri("/books").header("X-API-Key", "key-free-demo"))
                .bodyJson().extractingPath("$.retryAfterSeconds").isEqualTo(3);

        clock.advance(Duration.ofSeconds(3));                       // one token is back, without sleeping
        assertThat(mvc.get().uri("/books").header("X-API-Key", "key-free-demo")).hasStatusOk();
        assertThat(mvc.get().uri("/books").header("X-API-Key", "key-free-demo")).hasStatus(429);
    }

    @Test
    void searchCostsFiveTokensAndKeysAreIndependent() {
        for (int expected : new int[] {15, 10, 5, 0}) {
            assertThat(mvc.get().uri("/books/search?q=dune").header("X-API-Key", "key-free-demo"))
                    .hasStatusOk().headers().hasValue("X-Rate-Limit-Remaining", String.valueOf(expected));
        }
        assertThat(mvc.get().uri("/books/search?q=dune").header("X-API-Key", "key-free-demo")).hasStatus(429);
        assertThat(mvc.get().uri("/books").header("X-API-Key", "key-pro-demo"))
                .hasStatusOk().headers().hasValue("X-Rate-Limit-Remaining", "199");
    }
}

What the test pins down is the contract a client depends on. Twenty requests return 200 and X-Rate-Limit-Remaining counting down from 19 to 0; the 21st returns 429 with Retry-After: 3, the remaining header 0, the media type application/problem+json and a body whose retryAfterSeconds is 3; advancing the clock three seconds admits one request and the next is rejected again. The second test checks the weights (/books/search costs five tokens: 15, 10, 5, 0, then 429) and that another API key has its own bucket. Because the context is cached and shared by both tests, a @BeforeEach removes the buckets with removeProxy; without it, the second test would inherit the first test’s empty bucket and fail depending on the order in which JUnit runs them.

Level 2: WebTestClient for the reactive filter

The WebFilter of the manual integration uses an AsyncProxyManager, so its test must run on a reactive server. WebTestClient bound to a real Netty server (RANDOM_PORT) exercises the filter chain exactly as a client would. The scratch module has the servlet stack, Spring Security, JDBC and Flyway on its classpath as well, which is why the test pins spring.main.web-application-type=reactive and excludes those auto-configurations; an application that has only WebFlux does not need the web-application-type property.

ReactiveWebTestClientTest.java
/** The WebFilter with an AsyncProxyManager, called through WebTestClient bound to a real Netty server. */
@SpringBootTest(classes = ReactiveApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        properties = {"spring.main.web-application-type=reactive",
                "spring.autoconfigure.exclude=org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration,"
                        + "org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration,"
                        + "org.springframework.boot.security.autoconfigure.web.reactive.ReactiveWebSecurityAutoConfiguration,"
                        + "org.springframework.boot.security.autoconfigure.actuate.web.reactive.ReactiveManagementWebSecurityAutoConfiguration,"
                        + "org.springframework.boot.security.autoconfigure.ReactiveUserDetailsServiceAutoConfiguration"})
@AutoConfigureWebTestClient
class ReactiveWebTestClientTest {

    @Autowired WebTestClient client;

    @Test
    void fiveAllowedThenAProblemJson429WithRetryAfter() {
        for (int expected = 4; expected >= 0; expected--) {
            client.get().uri("/books").header("X-API-Key", "reactive-demo")
                    .exchange()
                    .expectStatus().isOk()
                    .expectHeader().valueEquals("X-Rate-Limit-Remaining", String.valueOf(expected));
        }

        client.get().uri("/books").header("X-API-Key", "reactive-demo")
                .exchange()
                .expectStatus().isEqualTo(429)
                .expectHeader().valueEquals("Retry-After", "12")            // 60 s / 5 tokens
                .expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON)
                .expectBody()
                .jsonPath("$.status").isEqualTo(429)
                .jsonPath("$.retryAfterSeconds").isEqualTo(12);

        client.get().uri("/books").header("X-API-Key", "another-client")    // another key, another bucket
                .exchange()
                .expectStatus().isOk();
    }
}

The limit is five per minute, so the Retry-After of the sixth call is 12 seconds (60 s divided by 5 tokens). This test uses the real clock, which is acceptable here because the value is a ceiling of a number just under 12 seconds, but a limit with a shorter period would need the clientClock technique of the previous section: configure the ManualClock in the test configuration of the reactive application in the same way.

A test profile with tiny bandwidths

Production limits are too big to reach in a test: a pro plan of 200 requests per minute means 201 requests to see one 429, and a refill that happens while they run makes the count unreliable. The remedy is a Spring profile, in src/test/resources, that overrides only the numbers.

application-tiny.yml: the test profile
# Test profile: tiny limits that are reached in a handful of requests and do not refill during a test run.
bookshelf:
  rate-limit:
    plans:
      free: { capacity: 3,  period: 1h }
      pro:  { capacity: 50, period: 1h }

The profile is activated after the manual profile, and maps merge key by key: free and pro change, while partner, the API keys, the costs and the failure policy stay as the application defines them. Two choices make the tests exact. A capacity of 3 or 50 is reached in a handful of requests, and a period of one hour means that nothing refills while the test runs, so a count of exactly 50 admitted requests is a valid assertion and not a hope. The tests also use real HTTP, through a small helper that returns status, headers, body and duration:

Http.java: the helper of the tests with a real server
/** Tiny HTTP helper for the page tests: real requests against RANDOM_PORT, so servlet filters really run. */
final class Http {
    record Resp(int status, java.net.http.HttpHeaders headers, String body, long millis) {
        String h(String name) {
            return headers.firstValue(name).orElse(null);
        }
    }

    private static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();

    static String basic(String user, String password) {
        return "Basic " + Base64.getEncoder().encodeToString((user + ":" + password).getBytes());
    }

    static Resp get(int port, String path, String... headers) throws Exception {
        HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).timeout(Duration.ofSeconds(10));
        for (int i = 0; i < headers.length; i += 2) {
            request.header(headers[i], headers[i + 1]);
        }
        long start = System.nanoTime();
        HttpResponse<String> response = CLIENT.send(request.build(), HttpResponse.BodyHandlers.ofString());
        return new Resp(response.statusCode(), response.headers(), response.body(), (System.nanoTime() - start) / 1_000_000);
    }
}
TinyProfileTest.java
/** Profile "tiny" (src/test/resources/application-tiny.yml): free = 3 per hour, pro = 50 per hour. */
@SpringBootTest(classes = ManualApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles({"manual", "tiny"})
class TinyProfileTest {

    @LocalServerPort int port;
    @Autowired RateLimitProperties properties;

    @Test
    void theProfileOverridesOnlyTheKeysItNames() {
        assertThat(properties.plans().get("free").capacity()).isEqualTo(3);
        assertThat(properties.plans().get("partner").capacity()).isEqualTo(2000);   // untouched, from the manual profile
    }

    @Test
    void thirdRequestIsTheLastAndTheRetryAfterIsTwentyMinutes() throws Exception {
        for (int i = 0; i < 3; i++) {
            assertThat(Http.get(port, "/books", "X-API-Key", "key-free-demo").status()).isEqualTo(200);
        }
        Resp rejected = Http.get(port, "/books", "X-API-Key", "key-free-demo");
        assertThat(rejected.status()).isEqualTo(429);
        assertThat(rejected.h("Retry-After")).isEqualTo("1200");        // 3600 s / 3 tokens
    }

    @Test
    void twoHundredParallelRequestsAdmitExactlyFifty() throws Exception {
        int requests = 200;
        CountDownLatch go = new CountDownLatch(1);
        List<Future<Integer>> results = new ArrayList<>();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < requests; i++) {
                results.add(executor.submit(() -> {
                    go.await();                                           // release every thread at the same moment
                    return Http.get(port, "/books", "X-API-Key", "key-pro-demo").status();
                }));
            }
            go.countDown();
            long ok = 0;
            long tooMany = 0;
            for (Future<Integer> result : results) {
                int status = result.get();
                if (status == 200) {
                    ok++;
                } else if (status == 429) {
                    tooMany++;
                }
            }
            assertThat(ok).isEqualTo(50);                                 // not 49, not 51
            assertThat(tooMany).isEqualTo(requests - 50);
        }
    }
}

The first test guards the profile itself: if someone renames a plan, the profile would silently stop overriding it, and every later test would run against production numbers. The second checks the numbers a client sees: three requests, a 429 on the fourth, and Retry-After: 1200, an hour divided by three tokens. The third test is the strongest one of the page: 200 virtual threads, released together, send real HTTP requests with one pro key, and exactly 50 get 200 while exactly 150 get 429. It tests the filter, the service, the manager and the store together, under contention, with an assertion that has no tolerance.

Level 1: testing implicit configuration replacement

Implicit configuration replacement lets a deployment roll out new limits: each replica builds its proxies with a desired configuration version and the first one that sees a stored bucket with an older version replaces its configuration, inheriting tokens by a TokensInheritanceStrategy. The mechanics are on the distributed page; the test below makes each rule observable with a manual clock and a local Caffeine store, which implements the same protocol.

ImplicitReplacementTest.java
/** A plan change shipped as a new configuration version, applied by the first replica that sees it. */
class ImplicitReplacementTest {

    private static BucketConfiguration free(long capacity, String id) {
        return BucketConfiguration.builder()
                .addLimit(limit -> limit.capacity(capacity).refillGreedy(capacity, Duration.ofMinutes(1)).id(id))
                .build();
    }

    private final ManualClock clock = new ManualClock();
    private ProxyManager<String> proxyManager;

    @BeforeEach
    void store() {
        proxyManager = Bucket4jCaffeine.<String>builderFor(Caffeine.newBuilder()).clientClock(clock).build();
    }

    private Bucket replica(long version, BucketConfiguration configuration) {
        return proxyManager.builder()
                .withImplicitConfigurationReplacement(version, TokensInheritanceStrategy.PROPORTIONALLY)
                .build("key:free", () -> configuration);
    }

    @Test
    void theNewVersionReplacesTheStoredConfigurationAndScalesTheTokens() {
        Bucket v1 = replica(1, free(20, "free"));
        assertThat(v1.tryConsume(10)).isTrue();                          // 10 of 20 left

        Bucket v2 = replica(2, free(40, "free"));                        // the first request of a replica that has version 2
        assertThat(v2.getAvailableTokens()).isEqualTo(20);               // 10 left of 20  ->  20 left of 40
        assertThat(proxyManager.getProxyConfiguration("key:free").orElseThrow().getBandwidths()[0].getCapacity()).isEqualTo(40);
    }

    @Test
    void aReplicaStillOnTheOldVersionDoesNotRollTheConfigurationBack() {
        assertThat(replica(2, free(40, "free")).getAvailableTokens()).isEqualTo(40);   // a bucket is created lazily: this call stores version 2

        Bucket oldReplica = replica(1, free(20, "free"));                // rolling deployment: not yet restarted
        assertThat(oldReplica.getAvailableTokens()).isEqualTo(40);       // still the version-2 bucket
        assertThat(proxyManager.getProxyConfiguration("key:free").orElseThrow().getBandwidths()[0].getCapacity()).isEqualTo(40);
    }

    @Test
    void bandwidthsAreMatchedByIdSoAnAddedIdDefeatsTheInheritance() {
        Bucket v1 = replica(1, free(20, "free"));
        v1.tryConsume(10);

        Bucket v2 = replica(2, free(40, "free-v2"));                     // a different id: a different bandwidth
        assertThat(v2.getAvailableTokens()).isEqualTo(40);               // not 20: the tokens were not inherited
    }
}

The three tests encode three rules. A replica with version 2 replaces the stored version-1 configuration and scales the tokens proportionally: ten tokens left of 20 become 20 left of 40, and the stored configuration now reports a capacity of 40. A replica that is still on version 1, during a rolling deployment, finds version 2 in the store and leaves it alone: the bucket stays at 40 and is not rolled back. And bandwidths are matched by id, so a new id defeats the inheritance: with free replaced by free-v2 the bucket ended with 40 tokens, not the 20 that proportional scaling would give, because the new bandwidth was treated as a different one. Use identical ids on both sides, or none on both.

One detail is easy to get wrong when writing such a test, and the first draft of the rollback test did. build(…​) returns a proxy without touching the store; the bucket is created, and its version stored, by the first command. A draft that built the version-2 proxy and then the version-1 proxy asserted 40 tokens and got 20, because no bucket existed when the old replica issued the first command, so the old replica created it at version 1. The test above makes the version-2 replica issue a command (getAvailableTokens()) before the old replica arrives.

Level 3: Testcontainers with Redis and PostgreSQL

A local Caffeine store proves the bucket logic; only the real store proves that the state is shared between replicas and that the atomicity mechanism (compare-and-swap on Redis, a row lock on PostgreSQL) holds. Testcontainers starts both in a few seconds. The conventions of these tests, @Testcontainers, @Container, @ServiceConnection and @DynamicPropertySource, are explained on integration tests with Testcontainers; the Resilience4j side of the same setup is on testing resilience.

Redis: two replicas and many threads

Two RedisClient objects, each with its own connection and its own ProxyManager, stand for two replicas. They consume from one key, and the sum of what they were granted must equal the capacity.

RedisProxyManagerIT.java
/** The Redis ProxyManager against a real Redis: two "replicas" (two managers, two connections) and many threads. */
@Testcontainers
class RedisProxyManagerIT {

    static final BucketConfiguration HUNDRED_PER_HOUR = BucketConfiguration.builder()
            .addLimit(limit -> limit.capacity(100).refillIntervally(100, Duration.ofHours(1)))   // nothing refills during the test
            .build();

    @Container
    static final GenericContainer<?> REDIS = new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379);

    static RedisClient replicaA;
    static RedisClient replicaB;

    @BeforeAll
    static void connect() {
        RedisURI uri = RedisURI.create(REDIS.getHost(), REDIS.getMappedPort(6379));
        replicaA = RedisClient.create(uri);
        replicaB = RedisClient.create(uri);
    }

    @AfterAll
    static void disconnect() {
        replicaA.shutdown();
        replicaB.shutdown();
    }

    private static ProxyManager<String> manager(RedisClient client) {
        return Bucket4jLettuce.casBasedBuilder(client).build()
                .withMapper(key -> key.getBytes(StandardCharsets.UTF_8));
    }

    @Test
    void twoReplicasShareOneBucket() {
        ProxyManager<String> a = manager(replicaA);
        ProxyManager<String> b = manager(replicaB);

        long granted = 0;
        for (int i = 0; i < 60; i++) {                                    // 120 attempts for 100 tokens
            granted += a.builder().build("key:shared-a", () -> HUNDRED_PER_HOUR).tryConsume(1) ? 1 : 0;
            granted += b.builder().build("key:shared-a", () -> HUNDRED_PER_HOUR).tryConsume(1) ? 1 : 0;
        }
        assertThat(granted).isEqualTo(100);
    }

    @Test
    void sixtyFourThreadsOnTwoReplicasNeverConsumeMoreThanTheCapacity() throws Exception {
        ProxyManager<String> a = manager(replicaA);
        ProxyManager<String> b = manager(replicaB);
        AtomicInteger granted = new AtomicInteger();
        CountDownLatch go = new CountDownLatch(1);
        List<Future<?>> futures = new ArrayList<>();
        try (var pool = Executors.newFixedThreadPool(64)) {
            for (int t = 0; t < 64; t++) {
                ProxyManager<String> replica = t % 2 == 0 ? a : b;
                futures.add(pool.submit(() -> {
                    go.await();
                    for (int i = 0; i < 5; i++) {                         // 320 attempts for 100 tokens
                        if (replica.builder().build("key:shared-b", () -> HUNDRED_PER_HOUR).tryConsume(1)) {
                            granted.incrementAndGet();
                        }
                    }
                    return null;
                }));
            }
            go.countDown();
            for (Future<?> future : futures) {
                future.get();
            }
        }
        assertThat(granted.get()).isEqualTo(100);
    }
}

The first test alternates the replicas for 120 attempts and expects exactly 100 grants. The second runs 64 threads, half on each replica, against the same key: 320 attempts and again exactly 100 grants, which is the concurrency test of level 1 repeated over a real network with real compare-and-swap retries. Both use a limit of 100 per hour with intervally refill so that no token returns during the run, and each uses its own key so that the tests do not interfere with one another in the shared container.

PostgreSQL: the whole application with Flyway

The PostgreSQL test starts the complete application. @ServiceConnection points Boot’s DataSource at the container, the property spring.flyway.locations selects the migration that creates the bucket table, and bookshelf.rate-limit.store=postgres selects the select-for-update manager of the manual integration. The application’s application.yml excludes the DataSource and Flyway auto-configurations so that the Caffeine-based tests need no database; this test replaces that exclusion list with the single exclusion it needs.

PostgresProxyManagerIT.java
/** The whole application over PostgreSQL: Flyway creates the table, Bucket4j fills it. */
@Testcontainers
@SpringBootTest(classes = ManualApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        properties = {
                "bookshelf.rate-limit.store=postgres",
                "spring.flyway.locations=classpath:db/manual",
                "spring.autoconfigure.exclude=org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration"
        })
@ActiveProfiles({"manual", "tiny"})
class PostgresProxyManagerIT {

    @Container
    @ServiceConnection                                  // sets spring.datasource.url, username and password
    static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17-alpine");

    @LocalServerPort int port;
    @Autowired ProxyManager<String> proxyManager;
    @Autowired JdbcTemplate jdbc;

    @Test
    void theBucketLivesInTheBucketTable() throws Exception {
        for (int i = 0; i < 3; i++) {
            assertThat(Http.get(port, "/books", "X-API-Key", "key-free-demo").status()).isEqualTo(200);
        }
        Resp rejected = Http.get(port, "/books", "X-API-Key", "key-free-demo");
        assertThat(rejected.status()).isEqualTo(429);

        assertThat(proxyManager.getClass().getSimpleName()).isEqualTo("PostgreSQLSelectForUpdateBasedProxyManager");
        Long rows = jdbc.queryForObject("SELECT count(*) FROM bucket WHERE expires_at IS NOT NULL", Long.class);
        assertThat(rows).isEqualTo(1);                  // one key, one row, with an expiry
    }
}

Three requests pass, the fourth is rejected, the manager is the PostgreSQLSelectForUpdateBasedProxyManager, and the table holds exactly one row with an expiry for the one key that was used. The assertion on the manager’s class guards against a mis-spelled property silently leaving the Caffeine store in place, which would make the whole test pass for the wrong reason. These tests use @Testcontainers without disabledWithoutDocker, so a build without Docker fails instead of skipping; the Resilience4j testing page uses the opposite choice, and which one is right depends on whether the pipeline guarantees Docker.

Level 4: a Redis outage with Toxiproxy

The limiter’s most important behaviour is the one that runs when its store is down: the service either lets requests through (fail open) or refuses them (fail closed), and the Resilience4j CircuitBreaker around the proxy decides how quickly that policy applies. The manual integration page explains the design; this section tests it. Toxiproxy is a TCP proxy that can be told to misbehave between two calls of the same test: the application connects to Toxiproxy, which forwards to Redis, and the test calls Toxiproxy’s control API.

sequenceDiagram participant T as Test participant A as App with CircuitBreaker participant P as Toxiproxy participant R as Redis T->>A: GET /books A->>P: consume 1 token P->>R: forwarded R-->>A: 200, remaining header T->>P: add toxic timeout 0, Redis stalls T->>A: GET /books, three times A->>P: consume 1 token P--xA: nothing comes back Note over A: after 250 ms: 503, Retry-After 5, about 270 ms each T->>A: GET /books, fourth call Note over A: breaker open: 503 in a few ms T->>P: remove the toxic Note over A: after the 2 s open state: half-open probe succeeds T->>A: GET /books A-->>T: 200, breaker closed

The two containers share a Docker network so that Toxiproxy reaches Redis by its alias. The application is pointed at Toxiproxy’s mapped port through @DynamicPropertySource, and both containers and the proxy are created once, in a static initializer of an abstract base class, and shared by the two policy tests.

RedisOutageSupport.java: Redis behind Toxiproxy
/** Redis behind Toxiproxy: the application talks to the proxy, so the test decides when Redis "fails". */
abstract class RedisOutageSupport {

    static final Network NETWORK = Network.newNetwork();

    static final GenericContainer<?> REDIS = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
            .withNetwork(NETWORK).withNetworkAliases("redis").withExposedPorts(6379);

    static final ToxiproxyContainer TOXIPROXY = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.12.0")
            .withNetwork(NETWORK).withExposedPorts(8474, 8666);    // 8474: control API, 8666: the proxy listens here

    static Proxy redisProxy;

    static {
        REDIS.start();
        TOXIPROXY.start();
        try {
            var client = new ToxiproxyClient(TOXIPROXY.getHost(), TOXIPROXY.getControlPort());
            redisProxy = client.createProxy("redis", "0.0.0.0:8666", "redis:6379");
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
    }

    @DynamicPropertySource
    static void redisThroughTheProxy(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", TOXIPROXY::getHost);
        registry.add("spring.data.redis.port", () -> TOXIPROXY.getMappedPort(8666));
    }
}

Fail closed

With on-backend-failure=closed, a store that does not answer must produce 503 and, once the breaker is open, must produce it fast. The test uses the toxic timeout with a value of 0, which makes Toxiproxy keep the connection open and forward nothing: a hung server, the case that a refused connection does not reproduce, and the one that the client’s requestTimeout of 250 ms exists for. The breaker of the manual integration has a window of four calls and opens at 50% failures, so the healthy request at the start of the test and three failures fill it.

RedisOutageFailClosedIT.java
/** A Redis that stops answering, with the fail-closed policy: 503, then fast 503s from the open breaker, then recovery. */
@SpringBootTest(classes = ManualApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        properties = {"bookshelf.rate-limit.store=redis", "bookshelf.rate-limit.on-backend-failure=closed"})
@ActiveProfiles("manual")
class RedisOutageFailClosedIT extends RedisOutageSupport {

    @LocalServerPort int port;

    @Test
    void aHungRedisGivesA503ThenTheBreakerOpensAndTheServiceRecovers() throws Exception {
        assertThat(Http.get(port, "/books", "X-API-Key", "key-pro-demo").status()).isEqualTo(200);

        var hang = redisProxy.toxics().timeout("hang", ToxicDirection.DOWNSTREAM, 0);   // 0 = stall forever, no error
        try {
            for (int i = 0; i < 3; i++) {                                             // with the healthy call, fills the four-call window
                Resp r = Http.get(port, "/books", "X-API-Key", "key-pro-demo");
                assertThat(r.status()).isEqualTo(503);
                assertThat(r.h("Retry-After")).isEqualTo("5");
                assertThat(r.headers().firstValue("Content-Type")).hasValueSatisfying(v -> assertThat(v).startsWith("application/problem+json"));
                assertThat(r.millis()).isGreaterThanOrEqualTo(200);                   // the 250 ms requestTimeout was paid
            }
            Resp shortCircuited = Http.get(port, "/books", "X-API-Key", "key-pro-demo");
            assertThat(shortCircuited.status()).isEqualTo(503);
            assertThat(shortCircuited.millis()).isLessThan(100);                      // the open breaker answers at once
        } finally {
            hang.remove();
        }

        await().atMost(Duration.ofSeconds(15)).pollInterval(Duration.ofMillis(500)).untilAsserted(() -> {
            Resp r = Http.get(port, "/books", "X-API-Key", "key-pro-demo");
            assertThat(r.status()).isEqualTo(200);                                    // half-open probe succeeded
            assertThat(r.h("X-Rate-Limit-Remaining")).isNotNull();
        });
    }
}

The run observed 503 responses after 268 to 300 ms for the three calls that waited for the timeout, each with Retry-After: 5 and application/problem+json, and a fourth call that answered in 6 ms because the breaker was open. After the toxic is removed, the assertion polls until a request returns 200 with the remaining header: it needs the two-second open state to pass and then a half-open probe to succeed, so the test waits with Awaitility and does not sleep for a guessed time. The window arithmetic matters when writing the test: the healthy first request counts as one of the four calls, so it is the fourth call that is short-circuited, not the fifth.

Fail open

With on-backend-failure=open, the same outage must not stop the service, but it must not hide the fact that nobody is counting. This test uses the other failure mode, proxy.disable(), which makes connections be refused, and sends 30 requests with a free key whose plan allows 20 per minute.

RedisOutageFailOpenIT.java
/** The same outage with the fail-open policy: every request passes, but nobody is counting. */
@SpringBootTest(classes = ManualApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
        properties = {"bookshelf.rate-limit.store=redis", "bookshelf.rate-limit.on-backend-failure=open"})
@ActiveProfiles("manual")
class RedisOutageFailOpenIT extends RedisOutageSupport {

    @LocalServerPort int port;

    @Test
    void aDownRedisLetsRequestsThroughWithoutARemainingHeader() throws Exception {
        assertThat(Http.get(port, "/books", "X-API-Key", "key-free-demo").h("X-Rate-Limit-Remaining")).isNotNull();

        redisProxy.disable();                                                         // connections are refused
        try {
            for (int i = 0; i < 30; i++) {                                            // more than the free plan's 20 per minute
                Resp r = Http.get(port, "/books", "X-API-Key", "key-free-demo");
                assertThat(r.status()).isEqualTo(200);
                assertThat(r.h("X-Rate-Limit-Remaining")).isNull();                   // the limiter did not decide anything
            }
        } finally {
            redisProxy.enable();
        }
    }
}

All 30 requests return 200, which would be impossible under the limiter, and none carries X-Rate-Limit-Remaining: the service did not decide anything and says so by omitting the header. That absence is the assertion that makes the test valuable, since it distinguishes a limiter that is open on purpose from one that has quietly stopped working while still returning 200. How to count and alert on those fail-open events is on Bucket4j observability.

Testing the starter’s YAML

With bucket4j-spring-boot-starter the limits are YAML, and a YAML mistake is a production bug that the compiler cannot see. Three kinds of test cover it, in a separate small module that has the servlet starter (starter-servlet, starter-aop and cache-jcache), Caffeine’s JCache provider, Spring Security with a permit-all SecurityFilterChain and nothing of the manual integration, because the starter’s servlet and WebFlux modules cannot share a classpath with the manual integration’s beans. The application YAML is the production one, and a test profile reduces the numbers.

application.yml: the filter under test
spring:
  cache:
    type: jcache
    cache-names: buckets
    jcache:
      provider: com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider
bucket4j:
  enabled: true
  cache-to-use: jcache
  default-http-response-body: '{ "title": "Too many requests", "status": 429 }'
  filters:
    - id: bookshelf-api
      cache-name: buckets
      url: /books.*
      rate-limits:
        - cache-key: "getHeader('X-API-Key') ?: getRemoteAddr()"
          bandwidths:
            - { capacity: 20, time: 1, unit: minutes }
application-tiny.yml: three requests per hour
# Only the numbers change; the filter, its URL pattern and the cache key still come from application.yml
bucket4j:
  filters:
    - id: bookshelf-api
      cache-name: buckets
      url: /books.*
      rate-limits:
        - cache-key: "getHeader('X-API-Key') ?: getRemoteAddr()"
          bandwidths:
            - { capacity: 3, time: 1, unit: hours }

A profile file replaces a list in bucket4j.filters as a whole, not element by element, so the test profile repeats the filter with its id, url and cache-key and changes only the capacity and period.

Use a real server, not MockMvc

The starter registers its servlet filters from a WebServerFactoryCustomizer, a hook of the embedded server, and a filter that does not run in your test proves nothing. The first test therefore uses RANDOM_PORT and a real HTTP client.

StarterYamlTest.java
/** The starter's filters are registered with the embedded server, so the test needs a real server and a real client.
 *  It uses the same Http helper as the other real-server tests on this page. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("tiny")
class StarterYamlTest {

    @LocalServerPort int port;

    @Test
    void thirdRequestIsTheLastAndTheFourthIsRejected() throws Exception {
        for (int expected = 2; expected >= 0; expected--) {
            Http.Resp ok = Http.get(port, "/books", "X-API-Key", "key-a");
            assertThat(ok.status()).isEqualTo(200);
            assertThat(ok.h("X-Rate-Limit-Remaining")).isEqualTo(String.valueOf(expected));
        }

        Http.Resp rejected = Http.get(port, "/books", "X-API-Key", "key-a");
        assertThat(rejected.status()).isEqualTo(429);
        assertThat(rejected.h("X-Rate-Limit-Retry-After-Seconds")).isIn("1199", "1200");   // 3600 s / 3 tokens, truncated
        assertThat(rejected.h("Retry-After")).isNull();                                 // the starter does not send it
        assertThat(rejected.body()).isEqualTo("{ \"title\": \"Too many requests\", \"status\": 429 }");

        assertThat(Http.get(port, "/books", "X-API-Key", "key-b").status()).isEqualTo(200);    // another key, another bucket
    }

    @Test
    void urlsOutsideTheFilterPatternAreNotLimited() throws Exception {
        for (int i = 0; i < 10; i++) {
            assertThat(Http.get(port, "/other", "X-API-Key", "key-c").status()).isEqualTo(404);   // reaches MVC, not the limiter
        }
    }
}

The test shows the starter’s contract, which differs from the manual integration: the allowed responses carry X-Rate-Limit-Remaining (2, 1, 0), and the rejection carries X-Rate-Limit-Retry-After-Seconds but no Retry-After, with the body that the YAML configures. The value is 1199 or 1200 because the starter truncates the seconds of an hour divided by three tokens, and a few milliseconds have passed. A second key has its own bucket, and the second test checks that a URL outside the filter’s url regex is never limited: ten requests to /other all reach MVC and get 404. A client that needs Retry-After must add it itself, as the starter page shows.

The MockMvc trap

The next test documents why the first one is not a MockMvc test. With Spring Security on the classpath, MockMvc built by @AutoConfigureMockMvc did not apply the starter’s filter: ten requests against a limit of three all returned 200.

StarterMockMvcPitfallTest.java
/** Documents why a MockMvc test of the starter's YAML proves nothing. */
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("tiny")
class StarterMockMvcPitfallTest {

    @Autowired MockMvcTester mvc;

    @Test
    void mockMvcNeverRunsTheStartersServletFilter() {
        for (int i = 0; i < 10; i++) {                          // the YAML allows 3
            assertThat(mvc.get().uri("/books").header("X-API-Key", "key-mock")).hasStatusOk();
        }
    }
}

The result depends on the application, which is the real danger. In the module above, with a permit-all Spring Security configuration, the test passes because the filter is absent; in a variant of the same module without Spring Security, the same MockMvc requests were limited and the fourth returned 429. The cause was not isolated further. A MockMvc test of the starter can therefore pass or fail for reasons unrelated to the YAML. Treat MockMvc as a way to test your own controllers and filters, and use a running server for anything the starter registers.

A broken YAML must stop the application

The last test starts the application with an invalid bandwidth and expects the startup to fail with a message that names the property. The starter validates its properties with Bean Validation, so a zero capacity or a time unit that is not a duration fails at startup, long before the first request.

InvalidYamlTest.java
/** A mistake in the YAML must stop the application at startup, not at the first request. */
class InvalidYamlTest {

    private static final String BANDWIDTH = "--bucket4j.filters[0].rate-limits[0].bandwidths[0].";

    private static void start(String... overrides) {
        // command-line arguments beat application.yml; a list element they touch replaces the whole YAML element
        new SpringApplicationBuilder(StarterTestingApplication.class).web(WebApplicationType.SERVLET).run(overrides).close();
    }

    @Test
    void aZeroCapacityFailsTheStartup() {
        assertThatThrownBy(() -> start("--server.port=0", BANDWIDTH + "capacity=0", BANDWIDTH + "time=1", BANDWIDTH + "unit=minutes"))
                .hasStackTraceContaining("bandwidths[0].capacity");
    }

    @Test
    void aTimeUnitThatIsNotADurationFailsTheStartup() {
        assertThatThrownBy(() -> start("--server.port=0", BANDWIDTH + "capacity=5", BANDWIDTH + "time=1", BANDWIDTH + "unit=forever"))
                .hasStackTraceContaining("bandwidths[0].unit");
    }
}

Both failures print a Boot "application failed to start" report that names the property, for example the capacity of the first bandwidth of the first rate limit of the first filter and the reason that the value must be greater than zero, and the unit FOREVER with the reason that only time-based units from nanoseconds to days are supported. Two details of the test are worth copying. The overrides are command-line arguments, because SpringApplicationBuilder.properties(…​) sets default properties with the lowest precedence and application.yml would win. And an argument that touches a list element replaces the whole YAML element, which is why each test supplies capacity, time and unit together: setting only the capacity produced two violations, one of which was not the one under test.

The Bookshelf test suite

The tests above were run together, and they are what a service with this limiter would keep in its build.

How to run them
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
mvn -B -pl testing-app test       # 14 tests, no Docker, a few seconds
mvn -B -pl testing-app verify     # the 14 plus 5 integration tests that start Redis, PostgreSQL and Toxiproxy
mvn -B -f testing-starter-app/pom.xml test   # 5 tests of the starter's YAML

A sensible split for a pipeline: level 1 and 2 tests on every commit, because they take seconds; level 3 and 4 on every pull request, in a pipeline that guarantees Docker; and a periodic run of the outage tests against the real pre-production stack, as the chaos exercises on testing resilience describe. Before production, the checks that tests cannot replace are on the production checklist.

Pitfalls

Pitfall What happens and what to do

Sleeping to wait for a refill

Slow and flaky. Pass a ManualClock to withCustomTimePrecision (local bucket) or clientClock (ProxyManager) and advance it.

The clock is set on the wrong object

A clock given to one bucket does not affect a ProxyManager, and the other way round. In Spring tests, replace the whole ProxyManager bean with @Primary in a @TestConfiguration.

A custom TimeMeter that implements one method

The reference’s example does not compile: currentTimeNanos() and isWallClockBased() are both abstract.

A non-wall-clock meter given to clientClock

IllegalArgumentException at build time ("not wall-based clock"). Return true from isWallClockBased() in test clocks that a manager uses.

Buckets leak between tests

The application context is cached, and so is the ProxyManager bean with its buckets. Call removeProxy(key) in @BeforeEach, or use a different key per test.

An exact count with a refill running

A count of exactly 50 is only valid if no token returns during the run. Use a period of an hour in the test profile, or freeze the clock.

build(…​) in a replacement test

It does not touch the store; the first command does. A test that builds the new-version proxy and never uses it has not stored version 2.

A new bandwidth id in a replacement

The new bandwidth is a different bandwidth, so the tokens are not inherited and the bucket is full. Keep the ids equal, or leave them out.

Testing the starter with MockMvc

The starter’s servlet filter may not be part of the MockMvc: ten requests against a limit of three all passed in one application and were limited in another. Use RANDOM_PORT and a real client.

Expecting Retry-After from the starter

It sends X-Rate-Limit-Retry-After-Seconds, and the WebFlux filter sends no retry header at all. Assert the headers the starter documents, or add your own.

Overriding YAML with SpringApplicationBuilder.properties

Default properties have the lowest precedence, so application.yml wins and the override has no effect. Use command-line arguments or @SpringBootTest(properties = …​), and remember that a list element is replaced whole.

withExposedPorts on Toxiproxy hides the control port

The call replaces the default list, and startup fails with "Requested port (8474) is not mapped". Expose the control port 8474 and the proxy’s listening port.

Testing only proxy.disable()

A refused connection fails at once and does not exercise requestTimeout. Test the stalled case with a timeout toxic of 0 as well.

Counting the breaker window wrong

The healthy call before the outage is part of the window, so with four calls the fourth is the first to be short-circuited. Assert on the pattern (slow, slow, slow, fast), not on a fixed number of slow calls.

A Testcontainers test that skips silently

disabledWithoutDocker = true makes a build without Docker green. Choose deliberately, and make sure one pipeline runs the tests and reports their count.

Asserting fail-open by status only

A fail-open 200 looks identical to a healthy 200. Assert that X-Rate-Limit-Remaining is absent, and alert on the log line and the metric.

What changed

  • Spring Boot 4 splits the test starters by web stack (spring-boot-starter-webmvc-test, spring-boot-starter-webflux-test), moves @AutoConfigureMockMvc to org.springframework.boot.webmvc.test.autoconfigure and @AutoConfigureWebTestClient to org.springframework.boot.webtestclient.autoconfigure, and imports @LocalServerPort from org.springframework.boot.test.web.server. MockMvcTester, the AssertJ-style entry point of Spring Framework 6.2 and later, is what the MockMvc test above uses.

  • Testcontainers 2.0 renamed its modules (testcontainers-junit-jupiter, testcontainers-postgresql, testcontainers-toxiproxy) and moved the classes: org.testcontainers.postgresql.PostgreSQLContainer and org.testcontainers.toxiproxy.ToxiproxyContainer. GenericContainer stays in org.testcontainers.containers.

  • Bucket4j 8.21.0: TimeMeter has two abstract methods, so older test clocks need isWallClockBased(); build(key, supplier) replaced the deprecated build(key, configuration) used in older examples; ProxyManager clocks must be wall-clock based.

  • The starter 0.20.0-RC1 has no GA release at the time of writing and its servlet filter is registered through a server hook, which is why its tests need a running server.

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