Files
spring-boot-demo/resilience4j-circuit-breaker/docs/03-spring-retryable.md
T
asmhatre 320733265f 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.
2026-09-18 08:40:50 +00:00

65 lines
3.9 KiB
Markdown

# 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).