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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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();
|
||||
}
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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();
|
||||
}
|
||||
}
|
||||
+72
@@ -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();
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+150
@@ -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();
|
||||
}
|
||||
}
|
||||
+86
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user