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

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.