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.
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.
| Test class | Proves | Level | Docker |
|---|---|---|---|
|
Refill, |
1 |
No |
|
64 threads never get more than the capacity |
1 |
No |
|
A new configuration version replaces the stored one once |
1 |
No |
|
429, |
2 |
No |
|
The same contract on the |
2 |
No |
|
The |
2 |
No |
|
Two replicas share one Redis bucket, no over-consumption |
3 |
Yes |
|
The whole application over PostgreSQL and Flyway |
3 |
Yes |
|
503 and recovery, or 200 without counting |
4 |
Yes |
|
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.
<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>
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.
/** 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;
}
}
Source: Bucket4j reference: A custom time measurement strategy,
corrected against the TimeMeter source.
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.
/** 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.
/** 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.
/** 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");
}
}
Source: Spring Framework reference: MockMvcTester, Spring Boot reference: Testing with a mock environment and RFC 9110: Retry-After.
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.
/** 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();
}
}
Source: Spring Framework reference: WebTestClient and Spring Boot reference: Auto-configured WebFlux tests.
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.
# 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:
/** 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);
}
}
/** 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.
/** 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
}
}
Source: Bucket4j reference: Implicit configuration replacement and Bucket4j reference: TokensInheritanceStrategy.
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.
/** 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);
}
}
Source: Bucket4j reference: Lettuce, How compare-and-swap works in Redis and Testcontainers: JUnit 5 integration.
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.
/** 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
}
}
Source: Bucket4j reference: PostgreSQL, Spring Boot reference: Service connections and Testcontainers: PostgreSQL module.
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.
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.
/** 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.
/** 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.
/** 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();
}
}
}
Source: Resilience4j: CircuitBreaker and Toxiproxy.
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.
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 }
# 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.
/** 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.
/** 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.
/** 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.
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 |
The clock is set on the wrong object |
A clock given to one bucket does not affect a |
A custom |
The reference’s example does not compile: |
A non-wall-clock meter given to |
|
Buckets leak between tests |
The application context is cached, and so is the |
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. |
|
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 |
Expecting |
It sends |
Overriding YAML with |
Default properties have the lowest precedence, so |
|
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 |
A refused connection fails at once and does not exercise |
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 |
|
Asserting fail-open by status only |
A fail-open 200 looks identical to a healthy 200. Assert that |
What changed
-
Spring Boot 4 splits the test starters by web stack (
spring-boot-starter-webmvc-test,spring-boot-starter-webflux-test), moves@AutoConfigureMockMvctoorg.springframework.boot.webmvc.test.autoconfigureand@AutoConfigureWebTestClienttoorg.springframework.boot.webtestclient.autoconfigure, and imports@LocalServerPortfromorg.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.PostgreSQLContainerandorg.testcontainers.toxiproxy.ToxiproxyContainer.GenericContainerstays inorg.testcontainers.containers. -
Bucket4j 8.21.0:
TimeMeterhas two abstract methods, so older test clocks needisWallClockBased();build(key, supplier)replaced the deprecatedbuild(key, configuration)used in older examples;ProxyManagerclocks 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.