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
3.6 KiB
3. @Retryable, measured
← 2. Enabling · Index · Next: 4. @ConcurrencyLimit →
Every number here is from FlakyGateway,
which records each real invocation in CallLog.
Counting
| Scenario | Invocations | Gaps (ms) | Caller got | Transcript |
|---|---|---|---|---|
| defaults, fails twice | 3 | 1009, 1001 | the value | retry-defaults.txt |
| defaults, always fails | 4 | 1001, 1001, 1002 | TransientException: attempt 4 failed |
retry-exhausted.txt |
maxRetries=5, delay=100, multiplier=2, maxDelay=500 |
6 | 101, 200, 401, 501, 501 | last exception | retry-exponential.txt |
delay=200, jitter=100 |
7 | 254, 211, 296, 294, 273, 293 | last exception | retry-jitter.txt |
- The caller receives the last original exception, not a wrapper. (
RetryTemplate.executethrowsRetryException; the annotation path unwraps it.) maxDelaycaps 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). 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). A counter of events with retryAborted=false
counts failures, not retries. Spring registers no retry metrics of its own
(metrics-names.txt): app.retry.failures exists only because
RetryEvents creates it.