Add resilience: Spring Framework 7 @Retryable and @ConcurrencyLimit

Companion code for "Spring Framework 7's Built-in Resilience: @Retryable,
@ConcurrencyLimit, and What's Left for Resilience4j". Every retry counted
by recording real invocations: defaults, backoff and jitter, timeout,
reactive and CompletableFuture returns, the concurrency limit's BLOCK and
REJECT policies, retries around transactions, composition with
Resilience4j 2.4.0, and the annotation API across 7.0.0-7.0.9.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01C3TETMrqVUWeFkNtz3Jbo3
This commit is contained in:
2026-09-11 17:12:25 +00:00
co-authored by Claude Opus 5
parent 926250e1a9
commit 7e1676c763
61 changed files with 2155 additions and 0 deletions
@@ -0,0 +1,12 @@
package com.ankurm.resilience;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ResilienceApplication {
public static void main(String[] args) {
SpringApplication.run(ResilienceApplication.class, args);
}
}
@@ -0,0 +1,17 @@
package com.ankurm.resilience;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.resilience.annotation.EnableResilientMethods;
/**
* Spring Boot 4.1 does not switch the resilience annotations on for you. Without this,
* {@code @Retryable} and {@code @ConcurrencyLimit} are inert - no proxy, no error, no log line.
* The {@code demo.resilience.enabled=false} switch exists only so the article can show that
* (docs/02-enabling.md).
*/
@Configuration
@EnableResilientMethods
@ConditionalOnBooleanProperty(name = "demo.resilience.enabled", matchIfMissing = true)
public class ResilienceConfig {
}
@@ -0,0 +1,82 @@
package com.ankurm.resilience.limit;
import com.ankurm.resilience.support.CallLog;
import org.springframework.resilience.annotation.ConcurrencyLimit;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
/**
* An expensive operation that must not run more than twice at once. The default policy BLOCKS
* callers beyond the limit; REJECT (added in 7.0.3) throws
* {@code InvocationRejectedException} instead. See docs/04-concurrency-limit.md.
*/
@Service
public class ReportService {
private final CallLog log;
public ReportService(CallLog log) {
this.log = log;
}
@ConcurrencyLimit(2)
public String blocking() {
return work();
}
@ConcurrencyLimit(limit = 2, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
public String rejecting() {
return work();
}
private final ThreadLocal<Integer> attemptsOnThisThread = ThreadLocal.withInitial(() -> 0);
/**
* Both annotations on one method. Every caller's first attempt fails and is retried after
* 300 ms. Whether the permit is held during that 300 ms depends on which interceptor is
* outermost - measured in docs/output/limit-and-retry.txt.
*/
@ConcurrencyLimit(1)
@Retryable(maxRetries = 1, delay = 300)
public String limitedAndRetried() {
int attempt = attemptsOnThisThread.get() + 1;
attemptsOnThisThread.set(attempt);
log.enter();
log.record();
try {
Thread.sleep(50);
if (attempt == 1) {
throw new IllegalStateException("first attempt fails");
}
attemptsOnThisThread.remove();
return "ok";
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return "interrupted";
}
finally {
log.exit();
}
}
public String unlimited() {
return work();
}
private String work() {
log.enter();
log.record();
try {
Thread.sleep(200);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
finally {
log.exit();
}
return "rendered";
}
}
@@ -0,0 +1,95 @@
package com.ankurm.resilience.r4j;
import java.util.concurrent.CompletableFuture;
import com.ankurm.resilience.support.CallLog;
import com.ankurm.resilience.support.TransientException;
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
/**
* The same kind of downstream, guarded by Resilience4j 2.4.0 instead - the things Spring Framework
* 7 does not do (circuit breaking, time limiting, a rejecting bulkhead with metrics), and one
* method that carries annotations from both libraries to show how they nest.
* Instances are configured in application.yaml. See docs/06-what-is-left-for-resilience4j.md.
*/
@Service
public class PaymentClient {
private final CallLog log;
private volatile boolean failing = true;
public PaymentClient(CallLog log) {
this.log = log;
}
public void setFailing(boolean failing) {
this.failing = failing;
}
private String call() {
int n = log.record();
if (failing) {
throw new TransientException("payment gateway 503 (call " + n + ")");
}
return "charged";
}
/** Resilience4j retry: maxAttempts counts the FIRST call too. */
@Retry(name = "payments")
public String r4jRetry() {
return call();
}
@CircuitBreaker(name = "payments")
public String charge() {
return call();
}
/** Both libraries on one method. Which one is on the outside decides what the breaker counts. */
@CircuitBreaker(name = "combo")
@Retryable(maxRetries = 3, delay = 10)
public String chargeWithBoth() {
return call();
}
@Bulkhead(name = "reports")
public String bulkheadReport() {
log.enter();
log.record();
try {
Thread.sleep(200);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
finally {
log.exit();
}
return "rendered";
}
/** TimeLimiter only applies to asynchronous return types. */
@TimeLimiter(name = "slow")
public CompletableFuture<String> slowAsync() {
return CompletableFuture.supplyAsync(() -> {
log.record();
sleep(2000);
return "too late";
});
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,128 @@
package com.ankurm.resilience.retry;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import com.ankurm.resilience.support.CallLog;
import com.ankurm.resilience.support.TransientException;
import reactor.core.publisher.Mono;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
/**
* A downstream that fails a configurable number of times before succeeding. Every method records
* each real invocation in {@link CallLog}, so the demos count what actually ran.
*
* <p>Each method isolates one behaviour of Spring Framework 7's {@code @Retryable}; the
* docs/03-retryable-measured.md chapter has the transcript for each.
*/
@Service
public class FlakyGateway {
private final CallLog log;
private final AtomicInteger failuresLeft = new AtomicInteger();
public FlakyGateway(CallLog log) {
this.log = log;
}
/** Fail the next {@code n} invocations, then succeed. */
public void failNext(int n) {
failuresLeft.set(n);
}
private String attempt() {
int call = log.record();
if (failuresLeft.getAndDecrement() > 0) {
throw new TransientException("attempt " + call + " failed");
}
return "ok after " + call + " invocation(s)";
}
/** All defaults: any exception, maxRetries = 3, delay = 1000 ms, no back-off growth. */
@Retryable
public String defaults() {
return attempt();
}
/** Exponential: 100, 200, 400, 800 ms - capped at maxDelay. */
@Retryable(maxRetries = 5, delay = 100, multiplier = 2, maxDelay = 500)
public String exponential() {
return attempt();
}
/** delay 200 +/- jitter 100. */
@Retryable(maxRetries = 6, delay = 200, jitter = 100)
public String jittered() {
return attempt();
}
/** Only IllegalStateException is retryable; everything else fails on the first attempt. */
@Retryable(includes = IllegalStateException.class, delay = 10)
public String onlyIllegalState() {
log.record();
throw new IllegalArgumentException("not in includes");
}
/**
* includes = IOException, but what is thrown is an UncheckedIOException (a RuntimeException)
* that WRAPS an IOException. Does the cause count?
*/
@Retryable(includes = IOException.class, maxRetries = 2, delay = 10)
public String wrappedCause() {
int call = log.record();
throw new UncheckedIOException(new IOException("socket reset on attempt " + call));
}
/**
* A CompletableFuture that completes exceptionally. The method itself returns normally, so
* from the interceptor's point of view there is nothing to retry.
*/
@Retryable(delay = 10)
public CompletableFuture<String> future() {
try {
return CompletableFuture.completedFuture(attempt());
}
catch (RuntimeException ex) {
return CompletableFuture.failedFuture(ex);
}
}
/** A Mono: retried by re-subscribing, so the deferred body runs again each time. */
@Retryable(delay = 10)
public Mono<String> mono() {
return Mono.fromCallable(this::attempt);
}
/** Each attempt takes 300 ms; the whole retry sequence has a 1 s budget. */
@Retryable(maxRetries = 10, delay = 100, timeout = 1000)
public String slowWithTimeout() {
sleep(300);
return attempt();
}
/** One attempt that hangs for 1.5 s, with a 500 ms budget. Is the attempt interrupted? */
@Retryable(maxRetries = 3, delay = 10, timeout = 500)
public String hangingWithTimeout() {
log.record();
sleep(1500);
throw new TransientException("slow failure");
}
/** Self-invocation: calls the @Retryable method on {@code this}, bypassing the proxy. */
public String selfInvocation() {
return defaults();
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,41 @@
package com.ankurm.resilience.retry;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.context.event.EventListener;
import org.springframework.resilience.retry.MethodRetryEvent;
import org.springframework.stereotype.Component;
/**
* Spring's retry support publishes a {@link MethodRetryEvent} per failed attempt and records no
* metrics of its own. This listener is the whole of the observability story unless you write
* one - here it keeps the events for the demos and turns them into a Micrometer counter.
*/
@Component
public class RetryEvents {
private final List<String> events = new CopyOnWriteArrayList<>();
private final MeterRegistry registry;
public RetryEvents(MeterRegistry registry) {
this.registry = registry;
}
@EventListener
void on(MethodRetryEvent event) {
events.add(event.getMethod().getName() + " failed with " + event.getFailure().getClass().getSimpleName()
+ (event.isRetryAborted() ? " -> retry aborted" : " -> will retry"));
registry.counter("app.retry.failures",
"method", event.getMethod().getName(),
"aborted", String.valueOf(event.isRetryAborted())).increment();
}
public List<String> drain() {
List<String> copy = List.copyOf(events);
events.clear();
return copy;
}
}
@@ -0,0 +1,66 @@
package com.ankurm.resilience.support;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.stereotype.Component;
/**
* Records every real invocation of a guarded method: when it happened (milliseconds after the
* first call) and on which thread. The demos report from this rather than from the retry
* machinery's own view, so what is counted is what actually ran.
*/
@Component
public class CallLog {
private final List<Long> times = new ArrayList<>();
private final List<String> threads = new ArrayList<>();
private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicInteger maxInFlight = new AtomicInteger();
private long start;
public synchronized void reset() {
times.clear();
threads.clear();
inFlight.set(0);
maxInFlight.set(0);
start = System.nanoTime();
}
public synchronized int record() {
times.add((System.nanoTime() - start) / 1_000_000);
threads.add(Thread.currentThread().isVirtual() ? "virtual" : Thread.currentThread().getName());
return times.size();
}
public void enter() {
int now = inFlight.incrementAndGet();
maxInFlight.accumulateAndGet(now, Math::max);
}
public void exit() {
inFlight.decrementAndGet();
}
public synchronized int count() {
return times.size();
}
public synchronized List<Long> times() {
return List.copyOf(times);
}
/** Gaps between consecutive invocations, in ms: the effective back-off. */
public synchronized List<Long> gaps() {
List<Long> gaps = new ArrayList<>();
for (int i = 1; i < times.size(); i++) {
gaps.add(times.get(i) - times.get(i - 1));
}
return gaps;
}
public int maxInFlight() {
return maxInFlight.get();
}
}
@@ -0,0 +1,8 @@
package com.ankurm.resilience.support;
/** What a flaky downstream throws: worth retrying. */
public class TransientException extends RuntimeException {
public TransientException(String message) {
super(message);
}
}
@@ -0,0 +1,24 @@
package com.ankurm.resilience.tx;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* The same retryable, transactional method - called from inside an existing transaction. Every
* attempt joins the caller's transaction, the first failure marks it rollback-only, and the
* retry that "succeeds" cannot un-mark it.
*/
@Service
public class OrderFacade {
private final StockWriter writer;
public OrderFacade(StockWriter writer) {
this.writer = writer;
}
@Transactional
public void placeOrder(String sku) {
writer.record(sku);
}
}
@@ -0,0 +1,62 @@
package com.ankurm.resilience.tx;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import javax.sql.DataSource;
import com.ankurm.resilience.support.TransientException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* {@code @Retryable} and {@code @Transactional} on the same method. Each attempt inserts a row and
* the first two then fail. What ends up in the table - and which transaction each attempt ran in -
* shows which interceptor is outside the other. See docs/05-retry-and-transactions.md.
*/
@Service
public class StockWriter {
private final JdbcTemplate jdbc;
private final DataSource dataSource;
private final AtomicInteger failuresLeft = new AtomicInteger();
private final List<String> attempts = new ArrayList<>();
public StockWriter(JdbcTemplate jdbc, DataSource dataSource) {
this.jdbc = jdbc;
this.dataSource = dataSource;
}
public void failNext(int n) {
failuresLeft.set(n);
attempts.clear();
jdbc.update("delete from stock_movement");
}
@Retryable(maxRetries = 3, delay = 10)
@Transactional
public void record(String sku) {
Object holder = TransactionSynchronizationManager.getResource(dataSource);
attempts.add("attempt " + (attempts.size() + 1)
+ ": transaction active=" + TransactionSynchronizationManager.isActualTransactionActive()
+ ", connection holder @" + Integer.toHexString(System.identityHashCode(holder)));
jdbc.update("insert into stock_movement(sku) values (?)", sku);
if (failuresLeft.getAndDecrement() > 0) {
throw new TransientException("deadlock victim (simulated)");
}
}
public int rows() {
Integer n = jdbc.queryForObject("select count(*) from stock_movement", Integer.class);
return n == null ? 0 : n;
}
public List<String> attempts() {
return List.copyOf(attempts);
}
}
@@ -0,0 +1,252 @@
package com.ankurm.resilience.web;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import com.ankurm.resilience.limit.ReportService;
import com.ankurm.resilience.r4j.PaymentClient;
import com.ankurm.resilience.retry.FlakyGateway;
import com.ankurm.resilience.retry.RetryEvents;
import com.ankurm.resilience.support.CallLog;
import com.ankurm.resilience.tx.OrderFacade;
import com.ankurm.resilience.tx.StockWriter;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.springframework.aop.framework.Advised;
import org.springframework.aop.support.AopUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
/**
* One endpoint per scenario. Each resets the call log, runs the scenario, and reports what really
* happened: how many times the guarded method ran, the gaps between runs, and what the caller got
* back. scripts/demo-*.sh curl these and commit the output. Delete before shipping anything real.
*/
@RestController
public class DemoController {
private final FlakyGateway gateway;
private final ReportService reports;
private final StockWriter writer;
private final OrderFacade facade;
private final PaymentClient payments;
private final CallLog log;
private final RetryEvents events;
private final CircuitBreakerRegistry breakers;
public DemoController(FlakyGateway gateway, ReportService reports, StockWriter writer, OrderFacade facade,
PaymentClient payments, CallLog log, RetryEvents events, CircuitBreakerRegistry breakers) {
this.gateway = gateway;
this.reports = reports;
this.writer = writer;
this.facade = facade;
this.payments = payments;
this.log = log;
this.events = events;
this.breakers = breakers;
}
@GetMapping("/demo/retry/{scenario}")
public Map<String, Object> retry(@PathVariable String scenario) {
log.reset();
events.drain();
return switch (scenario) {
case "defaults" -> run(2, gateway::defaults);
case "exhausted" -> run(99, gateway::defaults);
case "exponential" -> run(99, gateway::exponential);
case "jitter" -> run(99, gateway::jittered);
case "includes" -> run(0, gateway::onlyIllegalState);
case "cause" -> run(0, gateway::wrappedCause);
case "future" -> run(99, () -> gateway.future().get(5, TimeUnit.SECONDS));
case "mono" -> run(2, () -> gateway.mono().block());
case "timeout" -> run(99, gateway::slowWithTimeout);
case "hang" -> run(0, gateway::hangingWithTimeout);
case "self-invocation" -> run(2, gateway::selfInvocation);
default -> Map.of("error", "unknown scenario " + scenario);
};
}
@GetMapping("/demo/tx/{scenario}")
public Map<String, Object> tx(@PathVariable String scenario) {
events.drain();
writer.failNext(2);
Map<String, Object> out = new LinkedHashMap<>();
try {
if (scenario.equals("joined")) {
facade.placeOrder("SKU-1");
}
else {
writer.record("SKU-1");
}
out.put("caller got", "success");
}
catch (RuntimeException ex) {
out.put("caller got", ex.getClass().getName() + ": " + ex.getMessage());
}
out.put("attempts", writer.attempts());
out.put("rows in stock_movement", writer.rows());
return out;
}
@GetMapping("/demo/limit/{scenario}")
public Map<String, Object> limit(@PathVariable String scenario) throws Exception {
log.reset();
Callable<String> task = switch (scenario) {
case "block" -> reports::blocking;
case "reject" -> reports::rejecting;
case "r4j-bulkhead" -> payments::bulkheadReport;
case "limit-and-retry" -> reports::limitedAndRetried;
default -> reports::unlimited;
};
return concurrently(scenario.equals("limit-and-retry") ? 2 : 10, task);
}
@GetMapping("/demo/r4j/{scenario}")
public Map<String, Object> r4j(@PathVariable String scenario) throws Exception {
log.reset();
events.drain();
payments.setFailing(true);
return switch (scenario) {
case "retry" -> run(0, payments::r4jRetry);
case "breaker" -> breaker();
case "combo" -> combo();
case "timelimiter" -> run(0, () -> payments.slowAsync().get());
default -> Map.of("error", "unknown scenario " + scenario);
};
}
/** The advisor chain on a bean's proxy, outermost first. */
@GetMapping("/demo/proxy/{bean}")
public List<String> proxy(@PathVariable String bean) {
Object target = switch (bean) {
case "payments" -> payments;
case "stock-writer" -> writer;
case "reports" -> reports;
case "gateway" -> gateway;
default -> throw new IllegalArgumentException(bean);
};
List<String> chain = new ArrayList<>();
chain.add("proxy type: " + (AopUtils.isCglibProxy(target) ? "CGLIB" : AopUtils.isJdkDynamicProxy(target) ? "JDK" : "none"));
if (AopUtils.isAopProxy(target) && target instanceof Advised advised) {
for (var advisor : advised.getAdvisors()) {
chain.add(advisor.getAdvice().getClass().getName()
+ (advisor instanceof org.springframework.core.Ordered o ? " order=" + o.getOrder() : ""));
}
}
return chain;
}
private Map<String, Object> breaker() throws InterruptedException {
CircuitBreaker cb = breakers.circuitBreaker("payments");
cb.reset();
List<String> outcomes = new ArrayList<>();
for (int i = 1; i <= 8; i++) {
outcomes.add(i + ": " + outcome(payments::charge) + " [state after: " + cb.getState() + "]");
}
int reachedWhileClosed = log.count();
Thread.sleep(2100);
payments.setFailing(false);
outcomes.add("-- 2.1 s later, downstream recovered --");
for (int i = 9; i <= 11; i++) {
outcomes.add(i + ": " + outcome(payments::charge) + " [state after: " + cb.getState() + "]");
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("calls made", 11);
out.put("calls that reached the downstream", log.count());
out.put("of which before the breaker opened", reachedWhileClosed);
out.put("outcomes", outcomes);
return out;
}
private Map<String, Object> combo() {
CircuitBreaker cb = breakers.circuitBreaker("combo");
cb.reset();
Map<String, Object> out = new LinkedHashMap<>();
for (int call = 1; call <= 2; call++) {
int before = log.count();
long start = System.nanoTime();
String result = outcome(payments::chargeWithBoth);
Map<String, Object> row = new LinkedHashMap<>();
row.put("caller got", result);
row.put("method body ran", log.count() - before);
row.put("elapsed (ms)", (System.nanoTime() - start) / 1_000_000);
row.put("breaker: buffered / failed / not permitted", cb.getMetrics().getNumberOfBufferedCalls() + " / "
+ cb.getMetrics().getNumberOfFailedCalls() + " / " + cb.getMetrics().getNumberOfNotPermittedCalls());
row.put("breaker state", cb.getState().toString());
out.put("call " + call, row);
}
out.put("MethodRetryEvents", events.drain());
return out;
}
private Map<String, Object> run(int failures, Callable<?> action) {
gateway.failNext(failures);
long start = System.nanoTime();
Object result;
try {
result = "returned: " + action.call();
}
catch (Exception ex) {
Throwable t = ex instanceof ExecutionException && ex.getCause() != null ? ex.getCause() : ex;
result = "threw: " + t.getClass().getName() + ": " + t.getMessage();
}
long elapsed = (System.nanoTime() - start) / 1_000_000;
Map<String, Object> out = new LinkedHashMap<>();
out.put("invocations", log.count());
out.put("started at (ms)", log.times());
out.put("gaps (ms)", log.gaps());
out.put("elapsed (ms)", elapsed);
out.put("caller", result);
List<String> ev = events.drain();
if (!ev.isEmpty()) {
out.put("MethodRetryEvents", ev);
}
return out;
}
private Map<String, Object> concurrently(int callers, Callable<String> task) throws Exception {
long start = System.nanoTime();
int ok = 0;
Map<String, Integer> failures = new LinkedHashMap<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < callers; i++) {
futures.add(executor.submit(task));
}
for (Future<String> f : futures) {
try {
f.get();
ok++;
}
catch (ExecutionException ex) {
failures.merge(ex.getCause().getClass().getName(), 1, Integer::sum);
}
}
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("callers", callers);
out.put("succeeded", ok);
out.put("failed", failures);
out.put("max concurrently inside the method", log.maxInFlight());
out.put("elapsed (ms)", (System.nanoTime() - start) / 1_000_000);
return out;
}
private static String outcome(Callable<?> action) {
try {
return "returned " + action.call();
}
catch (Exception ex) {
return ex.getClass().getSimpleName() + ": " + ex.getMessage();
}
}
}
@@ -0,0 +1,53 @@
spring:
application:
name: resilience
threads:
virtual:
enabled: true
datasource:
url: jdbc:h2:mem:resilience;DB_CLOSE_DELAY=-1
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,circuitbreakerevents,retries,bulkheads,prometheus
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: true
# Resilience4j instances used by PaymentClient. Every value is spelled out: the library defaults
# (sliding window 100, minimum 100 calls, 60 s open) would need 100 calls before the breaker
# could open at all - see docs/06-what-is-left-for-resilience4j.md.
resilience4j:
retry:
instances:
payments:
max-attempts: 3
wait-duration: 10ms
circuitbreaker:
instances:
payments: &breaker
sliding-window-size: 10
minimum-number-of-calls: 5
failure-rate-threshold: 50
wait-duration-in-open-state: 2s
permitted-number-of-calls-in-half-open-state: 2
automatic-transition-from-open-to-half-open-enabled: false
combo: *breaker
bulkhead:
instances:
reports:
max-concurrent-calls: 2
max-wait-duration: 0
timelimiter:
instances:
slow:
timeout-duration: 500ms
cancel-running-future: true
+4
View File
@@ -0,0 +1,4 @@
create table if not exists stock_movement (
id bigint generated by default as identity primary key,
sku varchar(64) not null
);
@@ -0,0 +1,34 @@
package com.ankurm.resilience;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.springframework.resilience.annotation.ConcurrencyLimit;
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.resilience.annotation.Retryable;
import static org.assertj.core.api.Assertions.assertThat;
/** Defaults the article quotes, read from the annotations rather than from the documentation. */
class ApiSurfaceTest {
@Test
void retryableDefaults() throws Exception {
assertThat(Retryable.class.getMethod("maxRetries").getDefaultValue()).isEqualTo(3L);
assertThat(Retryable.class.getMethod("delay").getDefaultValue()).isEqualTo(1000L);
assertThat(Retryable.class.getMethod("multiplier").getDefaultValue()).isEqualTo(1.0d);
assertThat(Retryable.class.getMethod("timeout").getDefaultValue()).isEqualTo(0L);
}
@Test
void concurrencyLimitHasTwoPolicies() {
assertThat(Arrays.stream(ConcurrencyLimit.ThrottlePolicy.values()).map(Enum::name))
.containsExactly("BLOCK", "REJECT");
}
@Test
void enableResilientMethodsOrderIsLowestMinusOne() throws Exception {
assertThat(EnableResilientMethods.class.getMethod("order").getDefaultValue()).isEqualTo(Integer.MAX_VALUE - 1);
}
}
@@ -0,0 +1,104 @@
package com.ankurm.resilience;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import com.ankurm.resilience.limit.ReportService;
import com.ankurm.resilience.r4j.PaymentClient;
import com.ankurm.resilience.support.CallLog;
import com.ankurm.resilience.tx.OrderFacade;
import com.ankurm.resilience.tx.StockWriter;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.junit.jupiter.api.BeforeEach;
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.transaction.UnexpectedRollbackException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** How the resilience interceptors nest with transactions, with each other, and with Resilience4j. */
@SpringBootTest
class CompositionContractTest {
@Autowired StockWriter writer;
@Autowired OrderFacade facade;
@Autowired ReportService reports;
@Autowired PaymentClient payments;
@Autowired CircuitBreakerRegistry breakers;
@Autowired CallLog log;
@BeforeEach
void reset() {
log.reset();
payments.setFailing(true);
}
@Test
void retryWrapsTheTransactionSoEachAttemptRollsBackOnItsOwn() {
writer.failNext(2);
writer.record("SKU-1");
assertThat(writer.rows()).isEqualTo(1);
assertThat(writer.attempts()).hasSize(3);
}
@Test
void insideAnOuterTransactionTheRetryCannotSaveIt() {
writer.failNext(2);
assertThatThrownBy(() -> facade.placeOrder("SKU-1")).isInstanceOf(UnexpectedRollbackException.class);
assertThat(writer.rows()).isZero();
}
@Test
void blockPolicyQueuesAndRejectPolicyThrows() throws Exception {
assertThat(runConcurrently(10, reports::blocking)).isEmpty();
assertThat(log.maxInFlight()).isEqualTo(2);
log.reset();
assertThat(runConcurrently(10, reports::rejecting))
.hasSize(8).allSatisfy(t -> assertThat(t).isInstanceOf(InvocationRejectedException.class));
}
@Test
void springRetryIsOutsideTheResilience4jBreaker() {
CircuitBreaker cb = breakers.circuitBreaker("combo");
cb.reset();
assertThatThrownBy(payments::chargeWithBoth).isInstanceOf(RuntimeException.class);
assertThat(log.count()).isEqualTo(4);
assertThat(cb.getMetrics().getNumberOfBufferedCalls()).isEqualTo(4); // breaker saw every attempt
assertThatThrownBy(payments::chargeWithBoth).isInstanceOf(CallNotPermittedException.class);
assertThat(cb.getMetrics().getNumberOfNotPermittedCalls()).isEqualTo(3); // retried an open circuit
}
@Test
void resilience4jMaxAttemptsCountsTheFirstCall() {
assertThatThrownBy(payments::r4jRetry).isInstanceOf(RuntimeException.class);
assertThat(log.count()).isEqualTo(3);
}
private static List<Throwable> runConcurrently(int n, java.util.concurrent.Callable<String> task) throws Exception {
List<Throwable> failures = new ArrayList<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < n; i++) {
futures.add(executor.submit(task));
}
for (Future<String> f : futures) {
try {
f.get();
}
catch (java.util.concurrent.ExecutionException ex) {
failures.add(ex.getCause());
}
}
}
return failures;
}
}
@@ -0,0 +1,108 @@
package com.ankurm.resilience;
import java.io.UncheckedIOException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import com.ankurm.resilience.retry.FlakyGateway;
import com.ankurm.resilience.support.CallLog;
import com.ankurm.resilience.support.TransientException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Pins what Spring Framework 7.0.9's @Retryable actually does, counted by real invocations.
* The one-second default delay makes a few of these slow on purpose.
*/
@SpringBootTest
class RetryableContractTest {
@Autowired
FlakyGateway gateway;
@Autowired
CallLog log;
@BeforeEach
void reset() {
log.reset();
}
@Test
void maxRetriesThreeMeansFourInvocations() {
gateway.failNext(99);
assertThatThrownBy(gateway::defaults).isInstanceOf(TransientException.class)
.hasMessage("attempt 4 failed"); // the LAST original exception, not a wrapper
assertThat(log.count()).isEqualTo(4);
assertThat(log.gaps()).allSatisfy(gap -> assertThat(gap).isBetween(950L, 1300L));
}
@Test
void exceptionNotInIncludesIsNotRetried() {
assertThatThrownBy(gateway::onlyIllegalState).isInstanceOf(IllegalArgumentException.class);
assertThat(log.count()).isEqualTo(1);
}
@Test
void includesMatchesAWrappedCause() {
assertThatThrownBy(gateway::wrappedCause).isInstanceOf(UncheckedIOException.class);
assertThat(log.count()).isEqualTo(3);
}
@Test
void aFailedCompletableFutureIsNotRetried() {
gateway.failNext(99);
assertThatThrownBy(() -> gateway.future().get(5, TimeUnit.SECONDS))
.isInstanceOf(ExecutionException.class);
assertThat(log.count()).isEqualTo(1);
}
@Test
void aMonoIsRetriedByResubscribing() {
gateway.failNext(2);
assertThat(gateway.mono().block()).isEqualTo("ok after 3 invocation(s)");
assertThat(log.count()).isEqualTo(3);
}
@Test
void timeoutIsABudgetCheckedBetweenAttemptsNotAnInterrupt() {
long start = System.nanoTime();
assertThatThrownBy(gateway::hangingWithTimeout).isInstanceOf(TransientException.class);
long elapsed = (System.nanoTime() - start) / 1_000_000;
assertThat(log.count()).isEqualTo(1);
assertThat(elapsed).isGreaterThanOrEqualTo(1500); // the 500 ms budget did not cut it short
}
@Test
void selfInvocationBypassesTheProxy() {
gateway.failNext(1);
assertThatThrownBy(gateway::selfInvocation).isInstanceOf(TransientException.class);
assertThat(log.count()).isEqualTo(1);
}
@Nested
@SpringBootTest(properties = "demo.resilience.enabled=false")
class WithoutEnableResilientMethods {
@Autowired
FlakyGateway gateway;
@Autowired
CallLog log;
@Test
void theAnnotationIsInert() {
log.reset();
gateway.failNext(1);
assertThatThrownBy(gateway::defaults).isInstanceOf(TransientException.class);
assertThat(log.count()).isEqualTo(1);
}
}
}