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