Add resilience: Spring Framework 7 @Retryable and @ConcurrencyLimit

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
This commit is contained in:
2026-09-11 17:12:25 +00:00
co-authored by Claude Opus 5
parent 926250e1a9
commit 7e1676c763
61 changed files with 2155 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
# 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.