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,64 @@
|
||||
# 3. @Retryable: retries, but no memory
|
||||
|
||||
[Previous: 02-circuit-breaker.md](02-circuit-breaker.md) | [README](../README.md) | Next: [04-concurrency-limit.md](04-concurrency-limit.md)
|
||||
|
||||
Source: [`SpringRetryablePaymentService.java`](../src/main/java/com/ankurm/resilience/springresilience/SpringRetryablePaymentService.java).
|
||||
Tests: [`SpringRetryableTest.java`](../src/test/java/com/ankurm/resilience/springresilience/SpringRetryableTest.java).
|
||||
Transcripts: [`03a`](output/03a-retryable-recovers.txt), [`03b`](output/03b-retryable-no-memory.txt), [`03c`](output/03c-retryable-self-invocation.txt).
|
||||
|
||||
## The annotation
|
||||
|
||||
```java
|
||||
@Retryable(maxRetries = 3, delay = 200, multiplier = 2.0, timeUnit = TimeUnit.MILLISECONDS)
|
||||
public String pay(String orderId) {
|
||||
return downstream.call();
|
||||
}
|
||||
```
|
||||
|
||||
`maxRetries` is retries *after* the initial attempt, so `maxRetries=3` means up to 4 total
|
||||
attempts — confirmed by `03b-retryable-no-memory.txt`, where a permanently-dead downstream is
|
||||
called exactly 4 times per top-level `pay()` call. `delay`/`multiplier` give exponential
|
||||
backoff (200ms, then 400ms, matching the ~603ms elapsed time in `03a` for a call that succeeds
|
||||
on its 3rd attempt); `jitter` and `maxDelay` exist for the same reasons Resilience4j's
|
||||
`Retry.retryExceptions`-style config has them, but this repo doesn't exercise them — see the
|
||||
attribute list in [chapter 1](01-two-resilience-stacks.md).
|
||||
|
||||
There is no `fallbackMethod`. When retries are exhausted, the original exception is rethrown to
|
||||
the caller as-is (`03b` asserts the thrown type is `FlakyDownstream.DownstreamUnavailableException`,
|
||||
not some wrapper).
|
||||
|
||||
## The no-memory problem, demonstrated
|
||||
|
||||
`03b-retryable-no-memory.txt` calls `pay("order-A")` against a downstream that never recovers,
|
||||
exhausts all 4 attempts, and throws. It then calls `pay("order-B")` — a completely separate
|
||||
top-level call — immediately after. The transcript shows **another 4 downstream calls**, not
|
||||
zero. Compare with [`01-circuitbreaker-trip.txt`](output/01-circuitbreaker-trip.txt): after a
|
||||
Resilience4j breaker trips, calls 7 through 9 add zero downstream traffic, because the breaker
|
||||
carries state between calls that `@Retryable` structurally cannot: each invocation gets its own
|
||||
fresh `RetryPolicy` execution with no memory of the last one.
|
||||
|
||||
This is not a bug in `@Retryable` — it is not trying to be a circuit breaker. It is the
|
||||
specific gap the post is about: retry-with-backoff moved into core, but "stop calling a
|
||||
dependency you already know is down" did not.
|
||||
|
||||
## Same word, different arithmetic
|
||||
|
||||
Resilience4j's own `@Retry` sits right next to `@Retryable` in this repo for a direct
|
||||
comparison: [`R4jRetryService.java`](../src/main/java/com/ankurm/resilience/r4j/R4jRetryService.java),
|
||||
[`R4jRetryTest.java`](../src/test/java/com/ankurm/resilience/r4j/R4jRetryTest.java).
|
||||
[`03e-r4j-retry-exhaustion.txt`](output/03e-r4j-retry-exhaustion.txt) configures
|
||||
`maxAttempts=3` against a permanently-dead downstream and counts exactly 3 calls before it
|
||||
gives up. Core's `@Retryable(maxRetries=3)` against the same permanently-dead downstream
|
||||
(`03b`) makes 4 calls. Resilience4j's `maxAttempts` counts the initial call; core's `maxRetries`
|
||||
does not. Porting a number from one config to the other by name alone is off by one.
|
||||
|
||||
## The self-invocation trap still applies
|
||||
|
||||
`@Retryable` is proxy-based, exactly like Resilience4j's annotations, exactly like Spring's own
|
||||
`@Transactional` and `@Async`. Calling an annotated method on `this` from inside the same bean
|
||||
bypasses the proxy: [`03c-retryable-self-invocation.txt`](output/03c-retryable-self-invocation.txt)
|
||||
shows `payViaSelfInvocation()` making exactly one downstream call before throwing — no retry at
|
||||
all — because `this.pay(...)` never goes through the advised bean. If you've been burned by this
|
||||
with Resilience4j before, moving to core Spring buys you nothing here: same proxy model, same trap.
|
||||
|
||||
Next: [04-concurrency-limit.md](04-concurrency-limit.md).
|
||||
Reference in New Issue
Block a user