# 3. `@Retryable`, measured [← 2. Enabling](02-enabling.md) · [Index](../README.md) · Next: [4. @ConcurrencyLimit →](04-concurrency-limit.md) Every number here is from [`FlakyGateway`](../src/main/java/com/ankurm/resilience/retry/FlakyGateway.java), which records each real invocation in [`CallLog`](../src/main/java/com/ankurm/resilience/support/CallLog.java). ## Counting | Scenario | Invocations | Gaps (ms) | Caller got | Transcript | |---|---|---|---|---| | defaults, fails twice | 3 | 1009, 1001 | the value | [`retry-defaults.txt`](output/retry-defaults.txt) | | defaults, always fails | **4** | 1001, 1001, 1002 | `TransientException: attempt 4 failed` | [`retry-exhausted.txt`](output/retry-exhausted.txt) | | `maxRetries=5, delay=100, multiplier=2, maxDelay=500` | 6 | 101, 200, 401, 501, 501 | last exception | [`retry-exponential.txt`](output/retry-exponential.txt) | | `delay=200, jitter=100` | 7 | 254, 211, 296, 294, 273, 293 | last exception | [`retry-jitter.txt`](output/retry-jitter.txt) | - The caller receives the **last original exception**, not a wrapper. (`RetryTemplate.execute` throws `RetryException`; the annotation path unwraps it.) - `maxDelay` caps the exponential sequence: 400 would have been 800. ## Jitter only adds Jitter looks symmetric in the documentation. `ExponentialBackOff$ExponentialBackOffExecution.applyJitter` (read with `javap -c` on spring-core 7.0.9) computes the range as `[max(interval - j, initialInterval), min(interval + j, maxInterval)]`, where `j` is the jitter scaled by `interval / initialInterval`. For the first delay - and for every delay when `multiplier` is 1 - the lower bound is clamped to `delay` itself, so jitter can only lengthen the wait. The transcript agrees: six gaps between 211 and 296 ms, none under 200. ## What is retried | Scenario | Invocations | Why | |---|---|---| | `includes = IllegalStateException`, throws `IllegalArgumentException` | 1 | not included | | `includes = IOException`, throws `UncheckedIOException(IOException)` | 3 | **causes are matched** | | returns `CompletableFuture.failedFuture(...)` | **1** | the method returned normally | | returns `Mono.fromCallable(...)` that errors | 3 | retried by re-subscription | The `CompletableFuture` row is the one to remember: an async client method that returns a future is not protected by `@Retryable` at all ([`retry-future.txt`](output/retry-future.txt)). Reactive types go through a Reactor `retryWhen`, so a `Mono` works. ## `timeout` is a budget, not a timeout | Scenario | Invocations | Elapsed | | |---|---|---|---| | attempts of 300 ms, `delay=100`, `timeout=1000` | 3 | 1105 ms | stops once the budget is spent - after overrunning it by one attempt | | one attempt of 1500 ms, `timeout=500` | 1 | **1502 ms** | the attempt is not interrupted | The budget is checked between attempts. Use a client-level timeout (HTTP client, JDBC query timeout) for the call itself, and treat `timeout` as "stop retrying after roughly this long". ## Events are not a "retry happened" signal `MethodRetryEvent` fires for every failure with `isRetryAborted() == false`, *including the last one*, then once more with a `RetryException` and `isRetryAborted() == true`. For an exception that is not even retryable you get `will retry` followed by `retry aborted` ([`retry-includes.txt`](output/retry-includes.txt)). A counter of events with `retryAborted=false` counts failures, not retries. Spring registers no retry metrics of its own ([`metrics-names.txt`](output/metrics-names.txt)): `app.retry.failures` exists only because [`RetryEvents`](../src/main/java/com/ankurm/resilience/retry/RetryEvents.java) creates it.