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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user