Files
spring-boot-demo/resilience/docs/03-retryable-measured.md
T
asmhatreandClaude Opus 5 7e1676c763 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
2026-09-11 17:12:25 +00:00

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