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.
3.9 KiB
3. @Retryable: retries, but no memory
Previous: 02-circuit-breaker.md | README | Next: 04-concurrency-limit.md
Source: SpringRetryablePaymentService.java.
Tests: SpringRetryableTest.java.
Transcripts: 03a, 03b, 03c.
The annotation
@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.
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: 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,
R4jRetryTest.java.
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
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.