Testing Resilience4j in Spring Boot: Circuit Breakers, Retry, and Bulkheads
Resilience4j is the de-facto resilience library for Java applications, but most teams add it to production code and never actually verify it works. The circuit breaker is configured, the retry policy exists, and everyone feels safe — until a downstream service browns out and the circuit breaker stays closed because no one ever tested the threshold configuration.
This post covers how to test every major Resilience4j module in Spring Boot: circuit breakers, retry, bulkhead, and rate limiter. Real tests with assertions, not just "it compiled."
Project Setup
Add the dependencies to pom.xml:
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-micrometer</artifactId>
<version>2.1.0</version>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.35.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>Base application.yml configuration to test against:
resilience4j:
circuitbreaker:
instances:
paymentService:
registerHealthIndicator: true
slidingWindowSize: 10
minimumNumberOfCalls: 5
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
waitDurationInOpenState: 5s
failureRateThreshold: 50
eventConsumerBufferSize: 10
retry:
instances:
paymentService:
maxAttempts: 3
waitDuration: 100ms
retryExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
bulkhead:
instances:
paymentService:
maxConcurrentCalls: 5
maxWaitDuration: 0ms
ratelimiter:
instances:
paymentService:
limitForPeriod: 10
limitRefreshPeriod: 1s
timeoutDuration: 0msTesting Circuit Breakers
The circuit breaker has three states: CLOSED (normal), OPEN (failing fast), HALF_OPEN (probing). Tests need to exercise all three transitions.
@SpringBootTest
@ExtendWith(MockitoExtension.class)
class CircuitBreakerTest {
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@Autowired
private PaymentService paymentService;
@MockBean
private PaymentClient paymentClient;
private CircuitBreaker circuitBreaker;
@BeforeEach
void setUp() {
circuitBreaker = circuitBreakerRegistry.circuitBreaker("paymentService");
circuitBreaker.reset(); // Start from clean state
}
@Test
void shouldTransitionToOpenAfterFailureThreshold() {
// Arrange: configure client to always fail
when(paymentClient.charge(any()))
.thenThrow(new RuntimeException("Payment gateway down"));
// Act: make enough calls to trigger the threshold
// minimumNumberOfCalls=5, failureRateThreshold=50 → need 5 calls, 3+ failures
for (int i = 0; i < 5; i++) {
assertThrows(Exception.class,
() -> paymentService.processPayment(new PaymentRequest("100")));
}
// Assert: circuit breaker should now be OPEN
assertThat(circuitBreaker.getState())
.isEqualTo(CircuitBreaker.State.OPEN);
}
@Test
void shouldReturnFallbackWhenCircuitOpen() {
// Force open state directly
circuitBreaker.transitionToOpenState();
// Act
PaymentResult result = paymentService.processPayment(new PaymentRequest("100"));
// Assert: fallback response returned, not an exception
assertThat(result.isSuccess()).isFalse();
assertThat(result.getErrorCode()).isEqualTo("CIRCUIT_OPEN");
// Verify upstream was never called
verify(paymentClient, never()).charge(any());
}
@Test
void shouldTransitionToHalfOpenAfterWaitDuration() throws InterruptedException {
circuitBreaker.transitionToOpenState();
// Wait for the waitDurationInOpenState (5s in config — override for test)
// In tests, use a test-specific config with shorter duration
Thread.sleep(6000); // or use Awaitility
assertThat(circuitBreaker.getState())
.isEqualTo(CircuitBreaker.State.HALF_OPEN);
}
@Test
void shouldTransitionToClosedAfterSuccessfulProbes() {
circuitBreaker.transitionToHalfOpenState();
// Configure success responses for probes
when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));
// permittedNumberOfCallsInHalfOpenState=3 — need 3 successful calls
for (int i = 0; i < 3; i++) {
paymentService.processPayment(new PaymentRequest("100"));
}
assertThat(circuitBreaker.getState())
.isEqualTo(CircuitBreaker.State.CLOSED);
}
@Test
void shouldTrackMetricsCorrectly() {
when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));
paymentService.processPayment(new PaymentRequest("50"));
paymentService.processPayment(new PaymentRequest("50"));
CircuitBreaker.Metrics metrics = circuitBreaker.getMetrics();
assertThat(metrics.getNumberOfSuccessfulCalls()).isEqualTo(2);
assertThat(metrics.getNumberOfFailedCalls()).isEqualTo(0);
assertThat(metrics.getFailureRate()).isEqualTo(-1.0f); // -1 = not enough calls yet
}
}Testing with WireMock for Integration Confidence
Unit tests with mocks verify the circuit breaker state machine. Integration tests with WireMock verify the HTTP client actually triggers the circuit breaker:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@WireMockTest(httpPort = 8089)
class CircuitBreakerIntegrationTest {
@Autowired
private PaymentService paymentService;
@Autowired
private CircuitBreakerRegistry registry;
@Test
void shouldOpenCircuitOnRepeated503Responses(WireMockRuntimeInfo wmRuntimeInfo) {
// Simulate downstream returning 503
stubFor(post(urlEqualTo("/charge"))
.willReturn(aResponse()
.withStatus(503)
.withBody("{\"error\": \"Service Unavailable\"}")));
CircuitBreaker cb = registry.circuitBreaker("paymentService");
cb.reset();
// Make 5 calls (minimumNumberOfCalls threshold)
for (int i = 0; i < 5; i++) {
try {
paymentService.processPayment(new PaymentRequest("100"));
} catch (Exception ignored) {}
}
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
}
@Test
void shouldOpenCircuitOnConnectionTimeouts() {
// Simulate connection timeout
stubFor(post(urlEqualTo("/charge"))
.willReturn(aResponse()
.withFixedDelay(5000) // 5 second delay
.withStatus(200)));
// With a 1s timeout configured on the HTTP client, this will fail
CircuitBreaker cb = registry.circuitBreaker("paymentService");
cb.reset();
for (int i = 0; i < 5; i++) {
try {
paymentService.processPayment(new PaymentRequest("100"));
} catch (Exception ignored) {}
}
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
}
}Testing Retry Policies
Retry testing has two concerns: that retries happen the right number of times, and that they don't retry on non-retryable exceptions.
@SpringBootTest
class RetryTest {
@Autowired
private RetryRegistry retryRegistry;
@Autowired
private PaymentService paymentService;
@MockBean
private PaymentClient paymentClient;
@Test
void shouldRetryThreeTimesOnIOException() {
// First two calls throw, third succeeds
when(paymentClient.charge(any()))
.thenThrow(new IOException("Connection reset"))
.thenThrow(new IOException("Connection reset"))
.thenReturn(new ChargeResponse("OK"));
PaymentResult result = paymentService.processPayment(new PaymentRequest("100"));
assertThat(result.isSuccess()).isTrue();
verify(paymentClient, times(3)).charge(any());
}
@Test
void shouldNotRetryOnBusinessException() {
// InsufficientFundsException is not in retryExceptions config
when(paymentClient.charge(any()))
.thenThrow(new InsufficientFundsException("Not enough balance"));
assertThrows(InsufficientFundsException.class,
() -> paymentService.processPayment(new PaymentRequest("10000")));
// Should only be called once — no retry
verify(paymentClient, times(1)).charge(any());
}
@Test
void shouldExhaustRetriesAndThrow() {
when(paymentClient.charge(any()))
.thenThrow(new IOException("Persistent failure"));
assertThrows(MaxRetriesExceededException.class,
() -> paymentService.processPayment(new PaymentRequest("100")));
verify(paymentClient, times(3)).charge(any()); // maxAttempts=3
}
@Test
void shouldPublishRetryEvents() {
List<RetryEvent> events = new ArrayList<>();
Retry retry = retryRegistry.retry("paymentService");
retry.getEventPublisher().onRetry(events::add);
when(paymentClient.charge(any()))
.thenThrow(new IOException("fail"))
.thenReturn(new ChargeResponse("OK"));
paymentService.processPayment(new PaymentRequest("100"));
assertThat(events).hasSize(1);
assertThat(events.get(0).getNumberOfRetryAttempts()).isEqualTo(1);
}
}Testing Retry with Exponential Backoff
When using exponential backoff, you need to verify both the retry count and that delays are actually happening (or use test-friendly configuration):
# application-test.yml — override for tests
resilience4j:
retry:
instances:
paymentService:
maxAttempts: 3
waitDuration: 10ms # Much shorter than production 100ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2@Test
@ActiveProfiles("test")
void shouldApplyExponentialBackoff() {
AtomicLong lastCallTime = new AtomicLong(0);
List<Long> intervals = new ArrayList<>();
when(paymentClient.charge(any())).thenAnswer(inv -> {
long now = System.currentTimeMillis();
if (lastCallTime.get() > 0) {
intervals.add(now - lastCallTime.get());
}
lastCallTime.set(now);
throw new IOException("fail");
});
assertThrows(Exception.class,
() -> paymentService.processPayment(new PaymentRequest("100")));
// With 10ms base and multiplier 2: ~10ms, ~20ms between retries
assertThat(intervals).hasSize(2);
assertThat(intervals.get(1)).isGreaterThan(intervals.get(0));
}Testing Bulkheads
Bulkheads limit concurrent calls. Testing them requires actual concurrent execution:
@SpringBootTest
class BulkheadTest {
@Autowired
private BulkheadRegistry bulkheadRegistry;
@Autowired
private PaymentService paymentService;
@MockBean
private PaymentClient paymentClient;
@Test
void shouldRejectCallsExceedingConcurrentLimit() throws InterruptedException {
// maxConcurrentCalls=5 — hold 5 calls open, 6th should be rejected
CountDownLatch callsStarted = new CountDownLatch(5);
CountDownLatch release = new CountDownLatch(1);
when(paymentClient.charge(any())).thenAnswer(inv -> {
callsStarted.countDown();
release.await(); // Hold the call open
return new ChargeResponse("OK");
});
ExecutorService executor = Executors.newFixedThreadPool(6);
List<Future<?>> futures = new ArrayList<>();
// Launch 5 concurrent calls that block
for (int i = 0; i < 5; i++) {
futures.add(executor.submit(
() -> paymentService.processPayment(new PaymentRequest("100"))));
}
// Wait for all 5 to be in-flight
callsStarted.await(2, TimeUnit.SECONDS);
// 6th call should be rejected
assertThrows(BulkheadFullException.class,
() -> paymentService.processPayment(new PaymentRequest("100")));
// Release the held calls
release.countDown();
executor.shutdown();
}
@Test
void shouldAllowCallsAfterSlotFrees() throws InterruptedException {
CountDownLatch firstCallStarted = new CountDownLatch(1);
CountDownLatch releaseFirst = new CountDownLatch(1);
// Fill bulkhead to max-1 capacity then check a slot opens after one completes
Bulkhead bulkhead = bulkheadRegistry.bulkhead("paymentService");
// Directly acquire permits to set up state
for (int i = 0; i < 4; i++) {
bulkhead.acquirePermission();
}
assertThat(bulkhead.getMetrics().getAvailableConcurrentCalls()).isEqualTo(1);
// One more should work
bulkhead.acquirePermission();
assertThat(bulkhead.getMetrics().getAvailableConcurrentCalls()).isEqualTo(0);
// Release one
bulkhead.releasePermission();
assertThat(bulkhead.getMetrics().getAvailableConcurrentCalls()).isEqualTo(1);
}
}Testing Rate Limiters
Rate limiter tests verify that request limits are enforced per time period:
@SpringBootTest
class RateLimiterTest {
@Autowired
private RateLimiterRegistry rateLimiterRegistry;
@Autowired
private PaymentService paymentService;
@MockBean
private PaymentClient paymentClient;
@Test
void shouldAllowRequestsUpToLimit() {
when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));
RateLimiter rateLimiter = rateLimiterRegistry.rateLimiter("paymentService");
// limitForPeriod=10 per second
// 10 calls should succeed
for (int i = 0; i < 10; i++) {
PaymentResult result = paymentService.processPayment(new PaymentRequest("10"));
assertThat(result.isSuccess()).isTrue();
}
}
@Test
void shouldRejectRequestsExceedingLimit() {
when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));
// Make 11 calls — 11th should be rate limited
List<Exception> exceptions = new ArrayList<>();
for (int i = 0; i < 11; i++) {
try {
paymentService.processPayment(new PaymentRequest("10"));
} catch (RequestNotPermitted e) {
exceptions.add(e);
}
}
assertThat(exceptions).hasSize(1);
}
@Test
void shouldRefreshLimitAfterPeriod() throws InterruptedException {
when(paymentClient.charge(any())).thenReturn(new ChargeResponse("OK"));
// Exhaust the limit
for (int i = 0; i < 10; i++) {
paymentService.processPayment(new PaymentRequest("10"));
}
// Next call should fail
assertThrows(RequestNotPermitted.class,
() -> paymentService.processPayment(new PaymentRequest("10")));
// Wait for refresh (limitRefreshPeriod=1s)
Thread.sleep(1100);
// Should succeed again
PaymentResult result = paymentService.processPayment(new PaymentRequest("10"));
assertThat(result.isSuccess()).isTrue();
}
}Testing Combinations: Circuit Breaker + Retry
In production, you often stack decorators. The order matters — retry wrapping a circuit breaker behaves differently than a circuit breaker wrapping a retry:
@Test
void shouldNotRetryWhenCircuitIsOpen() {
CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentService");
cb.transitionToOpenState();
// With circuit open, retry should not attempt any calls
// because CallNotPermittedException is not in retryExceptions
assertThrows(CallNotPermittedException.class,
() -> paymentService.processPayment(new PaymentRequest("100")));
verify(paymentClient, never()).charge(any());
}
@Test
void shouldCountRetriedCallsAsMultipleCircuitBreakerCalls() {
// This tests the order: retry(circuitBreaker(call))
// Each retry attempt counts as a separate circuit breaker call
when(paymentClient.charge(any())).thenThrow(new IOException("fail"));
CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentService");
cb.reset();
try {
paymentService.processPayment(new PaymentRequest("100"));
} catch (Exception ignored) {}
// 3 retries × 1 call each = 3 failed calls recorded by circuit breaker
assertThat(cb.getMetrics().getNumberOfFailedCalls()).isEqualTo(3);
}Using Test-Specific Configuration
Avoid long wait durations in tests. Use Spring profiles to override timing:
@TestConfiguration
public class ResilienceTestConfig {
@Bean
@Primary
public CircuitBreakerConfig testCircuitBreakerConfig() {
return CircuitBreakerConfig.custom()
.slidingWindowSize(5)
.minimumNumberOfCalls(3)
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofMillis(100)) // 100ms vs 5s in prod
.permittedNumberOfCallsInHalfOpenState(2)
.build();
}
@Bean
@Primary
public CircuitBreakerRegistry testCircuitBreakerRegistry(CircuitBreakerConfig config) {
return CircuitBreakerRegistry.of(config);
}
}Then use it selectively:
@SpringBootTest
@Import(ResilienceTestConfig.class)
class FastCircuitBreakerTest {
// Tests run with 100ms open state instead of 5s
}Testing Resilience4j Actuator Endpoints
If you expose health and metrics via Actuator, test those too:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ResilienceActuatorTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private CircuitBreakerRegistry registry;
@Test
void shouldExposeCircuitBreakerHealth() {
ResponseEntity<Map> response = restTemplate.getForEntity(
"/actuator/health/circuitBreakers", Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsKey("components");
}
@Test
void shouldReportDegradedHealthWhenCircuitOpen() {
registry.circuitBreaker("paymentService").transitionToOpenState();
ResponseEntity<Map> response = restTemplate.getForEntity(
"/actuator/health", Map.class);
// Health should degrade when a circuit breaker is open
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
}
@Test
void shouldExposeMetricsForCircuitBreaker() {
ResponseEntity<Map> response = restTemplate.getForEntity(
"/actuator/metrics/resilience4j.circuitbreaker.calls", Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().get("name"))
.isEqualTo("resilience4j.circuitbreaker.calls");
}
}Common Pitfalls
Forgetting to reset state between tests. Circuit breakers hold state in singletons. Call circuitBreaker.reset() in @BeforeEach or you'll get test pollution.
Testing the wrong layer. If your service has @CircuitBreaker on a method called internally (not through the Spring proxy), the annotation won't fire. You need to call through the Spring bean, not this.method().
Ignoring the sliding window type. COUNT_BASED and TIME_BASED windows behave differently. Tests written for count-based windows will give misleading results if the production config uses time-based.
Not testing fallback logic. The circuit breaker opening is half the story. The fallback method is where business logic lives — test it gets the right inputs and returns sensible responses.
Concurrent test interference. If your test suite runs in parallel, circuit breaker singletons shared across tests will produce flaky results. Either use @DirtiesContext (expensive) or reset state explicitly in setup/teardown.
Summary
Testing Resilience4j effectively means testing state transitions, not just happy paths. The circuit breaker needs tests for CLOSED→OPEN, OPEN→HALF_OPEN, and HALF_OPEN→CLOSED transitions. Retry tests need to cover retryable vs non-retryable exceptions and exhaustion behavior. Bulkhead tests require actual concurrency. Rate limiter tests need to verify period refresh.
Use test-specific configuration to keep tests fast — 100ms open state instead of 5 seconds. Use WireMock for integration tests when you need to verify that your HTTP client configuration (timeouts, status codes) actually triggers the resilience patterns. And always reset circuit breaker state between tests.