Companion code for "Spring Framework 7's Built-in Resilience: @Retryable, @ConcurrencyLimit, and What's Left for Resilience4j". Every retry counted by recording real invocations: defaults, backoff and jitter, timeout, reactive and CompletableFuture returns, the concurrency limit's BLOCK and REJECT policies, retries around transactions, composition with Resilience4j 2.4.0, and the annotation API across 7.0.0-7.0.9. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01C3TETMrqVUWeFkNtz3Jbo3
62 lines
3.6 KiB
Markdown
62 lines
3.6 KiB
Markdown
# 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.
|