Add resilience4j-circuit-breaker: Resilience4j 2.4.0 vs Spring Framework 7 core, on Boot 4.1

Companion module for the rewritten post 'Resilience4j Circuit Breaker in Spring Boot 4.1:
What It's Still For', reworked around Framework 7 now shipping @Retryable/@ConcurrencyLimit
in core. Covers what's still Resilience4j's job (circuit breaker, rate limiter, bulkhead's
bounded wait, fallback methods, Actuator/Micrometer metrics), the off-by-one between
maxAttempts and maxRetries, and two Boot-4.1 build breaks: spring-boot-starter-aop no longer
exists (renamed to spring-boot-starter-aspectj, proven with Maven Central metadata and the
renamed starter's own POM -- see resilience/docs/08-starter-aop-renamed-to-starter-aspectj.md
in this same repo), and the resulting fix uses that renamed starter directly rather than
assembling spring-aop + aspectjweaver by hand. Kept as its own module rather than a new
top-level repository, alongside the existing resilience/ module for the sibling Framework-7
post.
This commit is contained in:
2026-09-18 08:40:50 +00:00
parent 604291067e
commit 320733265f
38 changed files with 1389 additions and 0 deletions
@@ -0,0 +1,50 @@
package com.ankurm.resilience;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicInteger;
/**
* A downstream call whose failure pattern is controlled by the test, not by chance.
* A deterministic fake beats a flaky "randomly fails 30% of the time" for teaching:
* the reader can predict exactly which call will fail and check the transcript against it.
*
* See docs/01-two-resilience-stacks.md.
*/
@Component
public class FlakyDownstream {
private final AtomicInteger callCount = new AtomicInteger();
/** Calls 1..failUntilInclusive throw; calls after that succeed. Set failUntilInclusive
* to Integer.MAX_VALUE to simulate a downstream that never recovers. */
private volatile int failUntilInclusive = 0;
public void configure(int failUntilInclusive) {
this.failUntilInclusive = failUntilInclusive;
this.callCount.set(0);
}
public void reset() {
this.callCount.set(0);
}
public int callsSoFar() {
return callCount.get();
}
/** The call every service in this repo ultimately makes. */
public String call() {
int n = callCount.incrementAndGet();
if (n <= failUntilInclusive) {
throw new DownstreamUnavailableException("payment-gateway rejected call #" + n);
}
return "OK (call #" + n + ")";
}
public static class DownstreamUnavailableException extends RuntimeException {
public DownstreamUnavailableException(String message) {
super(message);
}
}
}
@@ -0,0 +1,23 @@
package com.ankurm.resilience;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.resilience.annotation.EnableResilientMethods;
/**
* Companion app for the ankurm.com post "Resilience4j on Spring Boot 4.1: what it's still for".
*
* <p>{@code @EnableResilientMethods} is required explicitly — Spring Boot 4.1's
* autoconfigure jar carries no auto-configuration class for it (verified by grepping
* spring-boot-autoconfigure-4.1.1.jar for "resilien": zero matches). The annotations
* only start intercepting once this is present on a configuration class.
*
* See docs/01-two-resilience-stacks.md.
*/
@SpringBootApplication
@EnableResilientMethods
public class ResilienceBoot4DemoApplication {
public static void main(String[] args) {
SpringApplication.run(ResilienceBoot4DemoApplication.class, args);
}
}
@@ -0,0 +1,25 @@
package com.ankurm.resilience.r4j;
import io.github.resilience4j.bulkhead.BulkheadFullException;
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
/**
* Resilience4j's semaphore Bulkhead, for direct comparison with Spring's
* {@code @ConcurrencyLimit}. Config: resilience4j.bulkhead.instances.slowOp in application.yml,
* maxWaitDuration: 100ms — a bounded wait, which {@code @ConcurrencyLimit}'s BLOCK policy does
* not offer. See docs/04-concurrency-limit.md.
*/
@Service
public class R4jBulkheadService {
@Bulkhead(name = "slowOp", fallbackMethod = "fallback")
public String slowCall(String id, long sleepMillis) throws InterruptedException {
Thread.sleep(sleepMillis);
return "done:" + id;
}
private String fallback(String id, long sleepMillis, BulkheadFullException ex) {
return "REJECTED:" + id;
}
}
@@ -0,0 +1,31 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
/**
* Resilience4j's circuit breaker: a state machine (CLOSED / OPEN / HALF_OPEN) that
* remembers failure history *across calls*, sitting in front of the annotated method.
*
* Config lives in application.yml under resilience4j.circuitbreaker.instances.paymentService.
* See docs/02-circuit-breaker.md.
*/
@Service
public class R4jPaymentService {
private final FlakyDownstream downstream;
public R4jPaymentService(FlakyDownstream downstream) {
this.downstream = downstream;
}
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")
public String pay(String orderId) {
return downstream.call();
}
private String fallback(String orderId, Throwable ex) {
return "FALLBACK for " + orderId + ": " + ex.getClass().getSimpleName() + " - " + ex.getMessage();
}
}
@@ -0,0 +1,25 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
/**
* Resilience4j's own {@code @Retry}, for direct comparison with core's {@code @Retryable}
* (see {@link com.ankurm.resilience.springresilience.SpringRetryablePaymentService}). Config:
* resilience4j.retry.instances.inventoryService in application.yml. See docs/03-spring-retryable.md.
*/
@Service
public class R4jRetryService {
private final FlakyDownstream downstream;
public R4jRetryService(FlakyDownstream downstream) {
this.downstream = downstream;
}
@Retry(name = "inventoryService")
public String checkStock(String productId) {
return downstream.call();
}
}
@@ -0,0 +1,38 @@
package com.ankurm.resilience.springresilience;
import org.springframework.resilience.annotation.ConcurrencyLimit;
import org.springframework.stereotype.Service;
/**
* {@code @ConcurrencyLimit} is Spring Framework 7's version of what Resilience4j calls a
* semaphore Bulkhead: cap the number of concurrent invocations of a method. Two policies:
*
* <ul>
* <li>{@code BLOCK} (the default) — callers past the limit queue and wait.</li>
* <li>{@code REJECT} — callers past the limit get an
* {@code org.springframework.resilience.InvocationRejectedException} immediately (a
* {@link java.util.concurrent.RejectedExecutionException} subtype — verified with javap,
* not documented in the annotation's Javadoc).</li>
* </ul>
*
* Unlike Resilience4j's Bulkhead, there is no {@code maxWaitDuration} for the BLOCK policy —
* a blocked caller waits until a slot frees, with no timeout of its own. Under the hood BLOCK
* is implemented by extending {@code org.springframework.aop.interceptor.ConcurrencyThrottleInterceptor},
* a class that has shipped in Spring since the Spring 1.x era — repurposed, not reinvented.
* See docs/04-concurrency-limit.md.
*/
@Service
public class ConcurrencyLimitedService {
@ConcurrencyLimit(limit = 2)
public String slowCallBlocking(String id, long sleepMillis) throws InterruptedException {
Thread.sleep(sleepMillis);
return "done:" + id;
}
@ConcurrencyLimit(limit = 2, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
public String slowCallRejecting(String id, long sleepMillis) throws InterruptedException {
Thread.sleep(sleepMillis);
return "done:" + id;
}
}
@@ -0,0 +1,39 @@
package com.ankurm.resilience.springresilience;
import com.ankurm.resilience.FlakyDownstream;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* Spring Framework 7's own {@code @Retryable}: declarative retry with delay, multiplier
* and jitter, no separate dependency. There is no {@code fallbackMethod} attribute —
* unlike Resilience4j, exhausting retries just rethrows the last exception to the caller.
*
* And there is no circuit: each call to {@link #pay} starts its own retry loop from zero.
* Nothing here remembers that the last five calls all failed. See docs/03-spring-retryable.md.
*/
@Service
public class SpringRetryablePaymentService {
private final FlakyDownstream downstream;
public SpringRetryablePaymentService(FlakyDownstream downstream) {
this.downstream = downstream;
}
@Retryable(maxRetries = 3, delay = 200, multiplier = 2.0, timeUnit = TimeUnit.MILLISECONDS)
public String pay(String orderId) {
return downstream.call();
}
/**
* Self-invocation trap: calling {@code pay(...)} from inside this same bean bypasses
* the AOP proxy entirely, so no retry happens — the exact same trap Resilience4j's
* annotations have via Spring AOP proxies (both are proxy-based interceptors).
*/
public String payViaSelfInvocation(String orderId) {
return this.pay(orderId);
}
}
@@ -0,0 +1,48 @@
spring:
application:
name: resilience-boot4-demo
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,circuitbreakerevents
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: true
resilience4j:
circuitbreaker:
instances:
paymentService:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 2s
permittedNumberOfCallsInHalfOpenState: 2
automaticTransitionFromOpenToHalfOpenEnabled: true
recordExceptions:
- com.ankurm.resilience.FlakyDownstream$DownstreamUnavailableException
bulkhead:
instances:
slowOp:
maxConcurrentCalls: 2
maxWaitDuration: 100ms
retry:
instances:
inventoryService:
maxAttempts: 3
waitDuration: 200ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
logging:
level:
com.ankurm.resilience: INFO
@@ -0,0 +1,50 @@
package com.ankurm.resilience;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
/**
* Writes docs/output/NN-*.txt while a test runs, so every number quoted in the blog post
* is backed by a file produced by an assertion that would fail the build if it stopped
* being true. Never hand-edit files under docs/output/ — regenerate with scripts/run-all.sh.
*/
public final class Transcript {
private final StringBuilder buf = new StringBuilder();
private final Path outFile;
private Transcript(String fileName) {
this.outFile = Paths.get("docs/output", fileName);
}
public static Transcript start(String fileName, String header) {
Transcript t = new Transcript(fileName);
t.line(header);
t.line("=".repeat(header.length()));
return t;
}
public Transcript line(String s) {
buf.append(s).append('\n');
return this;
}
public Transcript blank() {
buf.append('\n');
return this;
}
public void save() {
try {
Files.createDirectories(outFile.getParent());
Files.writeString(outFile, buf.toString(), StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,54 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import com.ankurm.resilience.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Captures real /actuator/health and /actuator/circuitbreakers output with a breaker
* actually OPEN, over real HTTP against a running embedded server. Output:
* docs/output/05-actuator-health-tripped.txt. See docs/05-production-checklist.md.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext
class ActuatorHealthTest {
@Autowired
R4jPaymentService service;
@Autowired
FlakyDownstream downstream;
@LocalServerPort
int port;
@Test
void actuatorReportsCircuitOpenAfterRealTrip() {
Transcript t = Transcript.start("05-actuator-health-tripped.txt",
"Real /actuator/health and /actuator/circuitbreakers, captured over HTTP with the breaker actually OPEN");
downstream.configure(Integer.MAX_VALUE);
for (int i = 1; i <= 6; i++) {
service.pay("order-" + i);
}
RestClient rest = RestClient.create("http://localhost:" + port);
String health = rest.get().uri("/actuator/health").retrieve().body(String.class);
String breakers = rest.get().uri("/actuator/circuitbreakers").retrieve().body(String.class);
t.line("-- GET /actuator/health --");
t.line(health);
t.blank();
t.line("-- GET /actuator/circuitbreakers --");
t.line(breakers);
assertThat(health).contains("\"circuitBreakers\"");
assertThat(breakers).contains("\"state\":\"OPEN\"");
t.save();
}
}
@@ -0,0 +1,72 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import com.ankurm.resilience.Transcript;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves the claim the post makes about circuit breakers: the breaker *remembers* failure
* history across separate top-level calls, and while OPEN it does not even attempt the
* downstream call. Output: docs/output/01-circuitbreaker-trip.txt.
*/
@SpringBootTest
@DirtiesContext
class CircuitBreakerTripAndRecoverTest {
@Autowired
R4jPaymentService service;
@Autowired
FlakyDownstream downstream;
@Autowired
CircuitBreakerRegistry registry;
@Test
void tripsOpenStaysOpenThenRecoversThroughHalfOpen() throws InterruptedException {
Transcript t = Transcript.start("01-circuitbreaker-trip.txt",
"Resilience4j circuit breaker: trip, stay open, half-open, recover");
CircuitBreaker cb = registry.circuitBreaker("paymentService");
downstream.configure(Integer.MAX_VALUE); // downstream permanently down
t.line("Config: slidingWindowSize=10, minimumNumberOfCalls=5, failureRateThreshold=50%, waitDurationInOpenState=2s");
t.blank();
t.line("-- Phase 1: 6 calls against a dead downstream (only 6, to satisfy minimumNumberOfCalls=5) --");
for (int i = 1; i <= 6; i++) {
String result = service.pay("order-" + i);
t.line("call " + i + " -> " + result + " [breaker state=" + cb.getState() + "]");
}
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
int downstreamCallsAtTrip = downstream.callsSoFar();
t.blank();
t.line("Breaker state after 6 failing calls: " + cb.getState() + " (downstream was actually called " + downstreamCallsAtTrip + " times)");
t.blank();
t.line("-- Phase 2: 3 more calls while OPEN — these must NOT reach the downstream --");
for (int i = 7; i <= 9; i++) {
String result = service.pay("order-" + i);
t.line("call " + i + " -> " + result + " [breaker state=" + cb.getState() + ", downstream calls so far=" + downstream.callsSoFar() + "]");
}
assertThat(downstream.callsSoFar()).isEqualTo(downstreamCallsAtTrip);
t.line("Downstream call count unchanged (" + downstream.callsSoFar() + ") -- the breaker short-circuited all 3 calls without touching the downstream.");
t.blank();
t.line("-- Phase 3: wait 2.2s for waitDurationInOpenState, downstream now recovers, probe with permittedNumberOfCallsInHalfOpenState=2 --");
downstream.configure(0); // downstream now healthy
Thread.sleep(2200);
for (int i = 1; i <= 2; i++) {
String result = service.pay("probe-" + i);
t.line("half-open probe " + i + " -> " + result + " [breaker state=" + cb.getState() + "]");
}
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
t.line("Breaker state after 2 successful half-open probes: " + cb.getState());
t.save();
}
}
@@ -0,0 +1,60 @@
package com.ankurm.resilience.r4j;
import com.ankurm.resilience.FlakyDownstream;
import com.ankurm.resilience.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Resilience4j's own {@code @Retry}, same shape of test as {@code SpringRetryableTest}, for a
* direct side-by-side. Output: docs/output/03d-r4j-retry.txt.
*/
@SpringBootTest
@DirtiesContext
class R4jRetryTest {
@Autowired
R4jRetryService service;
@Autowired
FlakyDownstream downstream;
@Test
void retriesThreeTimesWithExponentialBackoffThenSucceeds() {
Transcript t = Transcript.start("03d-r4j-retry.txt",
"Resilience4j @Retry(maxAttempts=3, waitDuration=200ms, exponentialBackoffMultiplier=2): recovering from 1 transient failure");
downstream.configure(1); // first call fails, 2nd succeeds
long start = System.nanoTime();
String result = service.checkStock("sku-1");
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
t.line("downstream configured to fail its first call, then succeed");
t.line("result: " + result);
t.line("downstream was actually called " + downstream.callsSoFar() + " times");
t.line("elapsed: ~" + elapsedMs + "ms (expect >= 200ms wait before the 2nd attempt)");
assertThat(downstream.callsSoFar()).isEqualTo(2);
assertThat(result).startsWith("OK");
t.save();
}
@Test
void maxAttemptsCountsTotalAttemptsNotRetriesAfterTheFirst() {
Transcript t = Transcript.start("03e-r4j-retry-exhaustion.txt",
"Resilience4j @Retry(maxAttempts=3) against a permanently-dead downstream: counting convention");
downstream.configure(Integer.MAX_VALUE);
assertThatThrownBy(() -> service.checkStock("sku-2"))
.isInstanceOf(FlakyDownstream.DownstreamUnavailableException.class);
int calls = downstream.callsSoFar();
t.line("maxAttempts=3, downstream permanently down");
t.line("downstream calls before giving up: " + calls);
t.line("Resilience4j's maxAttempts is the TOTAL call count (initial attempt included): 3, not 4.");
t.line("Core's @Retryable(maxRetries=3) is 3 retries AFTER the initial attempt: 4 total");
t.line("(see docs/output/03b-retryable-no-memory.txt). Same-sounding config, different arithmetic --");
t.line("porting a maxRetries value from one to the other by name alone is off by one.");
assertThat(calls).isEqualTo(3);
t.save();
}
}
@@ -0,0 +1,150 @@
package com.ankurm.resilience.springresilience;
import com.ankurm.resilience.Transcript;
import com.ankurm.resilience.r4j.R4jBulkheadService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.resilience.InvocationRejectedException;
import org.springframework.test.annotation.DirtiesContext;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code @ConcurrencyLimit} vs Resilience4j's Bulkhead, both capped at 2 concurrent callers,
* both hit with 4 concurrent callers each sleeping 300ms. Output: docs/output/04-concurrency-limit.txt.
*/
@SpringBootTest
@DirtiesContext
class ConcurrencyLimitTest {
@Autowired
ConcurrencyLimitedService blockingService;
@Autowired
R4jBulkheadService bulkheadService;
private static final long SLEEP_MS = 300;
private static final int CALLERS = 4;
@Test
void blockPolicyQueuesInsteadOfRejecting() throws Exception {
Transcript t = Transcript.start("04a-concurrencylimit-block.txt",
"@ConcurrencyLimit(limit=2, policy=BLOCK), 4 concurrent callers, each sleeps 300ms");
ExecutorService pool = Executors.newFixedThreadPool(CALLERS);
CountDownLatch startGate = new CountDownLatch(1);
List<Callable<Long>> tasks = new java.util.ArrayList<>();
for (int i = 0; i < CALLERS; i++) {
int id = i;
tasks.add(() -> {
startGate.await();
long start = System.nanoTime();
blockingService.slowCallBlocking("c" + id, SLEEP_MS);
return (System.nanoTime() - start) / 1_000_000;
});
}
long wallStart = System.nanoTime();
startGate.countDown();
List<Future<Long>> futures = pool.invokeAll(tasks);
long totalWallMs = (System.nanoTime() - wallStart) / 1_000_000;
pool.shutdown();
List<Long> perCallMs = futures.stream().map(f -> {
try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); }
}).sorted().toList();
t.line("per-caller completion time (ms), sorted: " + perCallMs);
t.line("total wall time for all 4 callers: " + totalWallMs + "ms");
t.line("all 4 calls succeeded (BLOCK never rejects): true");
t.line("expectation: with limit=2 and 300ms per call, 4 callers must take roughly 2x300=600ms+,");
t.line("not ~300ms as they would with no limit at all.");
assertThat(totalWallMs).isGreaterThanOrEqualTo(550);
t.save();
}
@Test
void rejectPolicyFailsFastInsteadOfQueuing() throws Exception {
Transcript t = Transcript.start("04b-concurrencylimit-reject.txt",
"@ConcurrencyLimit(limit=2, policy=REJECT), 4 concurrent callers, each sleeps 300ms");
ExecutorService pool = Executors.newFixedThreadPool(CALLERS);
CountDownLatch startGate = new CountDownLatch(1);
List<Callable<String>> tasks = new java.util.ArrayList<>();
for (int i = 0; i < CALLERS; i++) {
int id = i;
tasks.add(() -> {
startGate.await();
long start = System.nanoTime();
try {
blockingService.slowCallRejecting("c" + id, SLEEP_MS);
long ms = (System.nanoTime() - start) / 1_000_000;
return "OK in " + ms + "ms";
} catch (InvocationRejectedException ex) {
long ms = (System.nanoTime() - start) / 1_000_000;
return "REJECTED (" + ex.getClass().getSimpleName() + ") in " + ms + "ms";
}
});
}
startGate.countDown();
List<Future<String>> futures = pool.invokeAll(tasks);
pool.shutdown();
List<String> outcomes = futures.stream().map(f -> {
try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); }
}).toList();
outcomes.forEach(o -> t.line(o));
long okCount = outcomes.stream().filter(o -> o.startsWith("OK")).count();
long rejectedCount = outcomes.stream().filter(o -> o.startsWith("REJECTED")).count();
t.blank();
t.line("OK: " + okCount + ", REJECTED: " + rejectedCount + " (expected 2 and 2 with limit=2, 4 callers)");
t.line("Rejection throws org.springframework.resilience.InvocationRejectedException");
t.line("(a RejectedExecutionException subtype) -- confirmed by javap, not documented on the annotation itself.");
assertThat(okCount).isEqualTo(2);
assertThat(rejectedCount).isEqualTo(2);
t.save();
}
@Test
void resilience4jBulkheadHasABoundedWaitThatConcurrencyLimitLacks() throws Exception {
Transcript t = Transcript.start("04c-r4j-bulkhead-comparison.txt",
"Resilience4j @Bulkhead(maxConcurrentCalls=2, maxWaitDuration=100ms), same 4-caller/300ms shape");
ExecutorService pool = Executors.newFixedThreadPool(CALLERS);
CountDownLatch startGate = new CountDownLatch(1);
List<Callable<String>> tasks = new java.util.ArrayList<>();
for (int i = 0; i < CALLERS; i++) {
int id = i;
tasks.add(() -> {
startGate.await();
long start = System.nanoTime();
String result = bulkheadService.slowCall("c" + id, SLEEP_MS);
long ms = (System.nanoTime() - start) / 1_000_000;
return result + " in " + ms + "ms";
});
}
startGate.countDown();
List<Future<String>> futures = pool.invokeAll(tasks, 5, TimeUnit.SECONDS);
pool.shutdown();
List<String> outcomes = futures.stream().map(f -> {
try { return f.get(); } catch (Exception e) { throw new RuntimeException(e); }
}).toList();
outcomes.forEach(o -> t.line(o));
long rejected = outcomes.stream().filter(o -> o.startsWith("REJECTED")).count();
t.blank();
t.line("REJECTED count: " + rejected + " -- these callers waited up to maxWaitDuration=100ms for a slot,");
t.line("then gave up and ran the fallback method, instead of blocking indefinitely like @ConcurrencyLimit's BLOCK policy.");
assertThat(rejected).isGreaterThan(0);
t.save();
}
}
@@ -0,0 +1,86 @@
package com.ankurm.resilience.springresilience;
import com.ankurm.resilience.FlakyDownstream;
import com.ankurm.resilience.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Proves the post's central claim about {@code @Retryable}: it retries, but it does not
* remember. Two back-to-back calls against a permanently-dead downstream each pay the full
* retry cost, unlike a tripped circuit breaker which fails fast on the very next call.
* Output: docs/output/03-spring-retryable.txt.
*/
@SpringBootTest
@DirtiesContext
class SpringRetryableTest {
@Autowired
SpringRetryablePaymentService service;
@Autowired
FlakyDownstream downstream;
@Test
void retriesThroughTransientFailureThenSucceeds() {
Transcript t = Transcript.start("03a-retryable-recovers.txt",
"@Retryable(maxRetries=3, delay=200ms, multiplier=2.0): recovering from 2 transient failures");
downstream.configure(2); // first 2 calls fail, 3rd succeeds
long start = System.nanoTime();
String result = service.pay("order-1");
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
t.line("downstream configured to fail its first 2 calls, then succeed");
t.line("result: " + result);
t.line("downstream was actually called " + downstream.callsSoFar() + " times");
t.line("elapsed: ~" + elapsedMs + "ms (expect >= 200ms delay before the 2nd attempt, plus backoff before the 3rd)");
assertThat(downstream.callsSoFar()).isEqualTo(3);
assertThat(result).startsWith("OK");
t.save();
}
@Test
void exhaustsRetriesAndHasNoMemoryOfIt() {
Transcript t = Transcript.start("03b-retryable-no-memory.txt",
"@Retryable against a permanently-dead downstream: two consecutive calls, no shared state");
downstream.configure(Integer.MAX_VALUE);
t.line("-- Call 1: pay(\"order-A\") --");
assertThatThrownBy(() -> service.pay("order-A"))
.isInstanceOf(FlakyDownstream.DownstreamUnavailableException.class);
int callsAfterFirst = downstream.callsSoFar();
t.line("threw FlakyDownstream.DownstreamUnavailableException after exhausting retries");
t.line("downstream calls so far: " + callsAfterFirst + " (1 initial attempt + 3 retries = 4 expected)");
assertThat(callsAfterFirst).isEqualTo(4);
t.blank();
t.line("-- Call 2: pay(\"order-B\"), immediately after Call 1 exhausted its retries --");
assertThatThrownBy(() -> service.pay("order-B"))
.isInstanceOf(FlakyDownstream.DownstreamUnavailableException.class);
int callsAfterSecond = downstream.callsSoFar();
t.line("downstream calls so far: " + callsAfterSecond + " (another 4 attempts, not fast-failed)");
assertThat(callsAfterSecond).isEqualTo(8);
t.blank();
t.line("Contrast with docs/output/01-circuitbreaker-trip.txt: there, calls 7-9 after the trip");
t.line("added ZERO downstream calls. Here, call 2 pays the same 4-attempt cost as call 1.");
t.line("@Retryable has no OPEN state -- it cannot tell you 'this dependency is currently down'.");
t.save();
}
@Test
void selfInvocationBypassesTheProxySilently() {
Transcript t = Transcript.start("03c-retryable-self-invocation.txt",
"@Retryable via self-invocation: the AOP proxy trap, same one that bites Resilience4j");
downstream.configure(Integer.MAX_VALUE);
t.line("Calling payViaSelfInvocation(...), which calls this.pay(...) from inside the same bean.");
assertThatThrownBy(() -> service.payViaSelfInvocation("order-Z"))
.isInstanceOf(FlakyDownstream.DownstreamUnavailableException.class);
int calls = downstream.callsSoFar();
t.line("downstream calls: " + calls + " (expected 1 -- no retry happened; the proxy was bypassed)");
assertThat(calls).isEqualTo(1);
t.save();
}
}