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,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);
}
}
}