Retry
pattern
Executes an operation, retrying on failure up to a configured number of attempts — with exponential backoff, optional jitter, and an optional overall deadline across the whole loop.
01
Execution flow
- Attempt — the operation runs.
- Success — returns immediately; a
Successevent is emitted and no further attempts are made. - Failure — an
AttemptFailedevent is emitted, thenshouldRetryis tested against the thrown exception. - Not retryable —
shouldRetryreturnsfalse→ aRejectedevent is emitted andRetryRejectedExceptionis thrown, even if this was the last available attempt. - Retryable, but out of budget — the attempt count has reached
maxAttempts, or the overall deadline has already elapsed → anExhaustedevent is emitted andRetryExhaustedExceptionis thrown. - Retryable, budget remains — the loop sleeps for the current backoff delay (with jitter applied and
maxDelayMsclamping it), then grows the delay bybackoffMultiplierand tries again. - Interrupted during the sleep — the thread's interrupt status is restored, an
Interruptedevent is emitted, andRetryInterruptedExceptionis thrown carrying the failure that was about to be retried. - Deadline check after the sleep — if the overall deadline passed while sleeping, the loop stops as Exhausted without making another attempt.
02
Behavior
maxAttempts counts the first call as an attempt — withMaxAttempts(1) disables retrying entirely. Backoff grows
exponentially: the first retry waits initialDelayMs, and every subsequent delay is the previous one multiplied by
backoffMultiplier. maxDelayMs caps every delay, including the initial one and after jitter is applied — a delay
above the cap is clamped, not rejected.
Jitter shifts each delay by a uniformly random offset within [delay * (1 - jitterFactor), delay * (1 + jitterFactor)],
spreading out retries from clients that failed at the same moment (thundering herd). A jitterFactor of 0.0 (the
default) disables jitter.
An optional overall deadline (withOverallDeadline) bounds the total wall-clock time spent across all attempts and
backoff waits, measured from the first attempt. It is checked only between attempts — it never preempts an attempt
already in progress, which stays a per-attempt Timeout's responsibility. Once it elapses, the loop stops exactly as
if the attempt budget itself had run out.
shouldRetry is evaluated before the attempt count and the deadline are checked. This means a non-retryable
exception always produces RetryRejectedException, even on the last attempt, rather than being reported as
RetryExhaustedException. By default, shouldRetry retries only IOException and its subclasses — other
exceptions are treated as permanent failures.
03
Configuration
| property | required | description |
|---|---|---|
| name | yes | Identifier used in events and exceptions (instance-specific) |
| withMaxAttempts() | no | Total attempts, including the first call. Default: 3 |
| withInitialDelay() | no | Wait before the first retry, in milliseconds. Default: 100ms |
| withBackoffMultiplier() | no | Factor each delay is multiplied by after a failed attempt. Default: 2.0 |
| withMaxDelay() | no | Cap on every backoff delay, applied after jitter. Default: uncapped |
| withJitter() | no | Uniform randomization factor for each delay, in [0.0, 1.0]. Default: 0.0 (off) |
| withOverallDeadline() | no | Total wall-clock budget across all attempts and backoff waits, checked between attempts. Default: uncapped |
| withShouldRetry() | no | Predicate deciding whether a thrown exception is retried. Default: retries only IOException and subclasses |
| withListener() | no | Subscribe to retry events (e.g. Success, AttemptFailed, Exhausted) |
| withClock() | no | Use a custom Clock instead of system (mainly for testing) |
04
Events & failure
- Success — the operation succeeded. Carries: timestamp, name, total attempts taken.
- AttemptFailed — an attempt was made and failed. Carries: timestamp, name, attempt number, the thrown exception.
- Rejected —
shouldRetrydeclined to retry a failure before the attempt budget was exhausted. Carries: timestamp, name, attempt number, the rejected exception. - Exhausted — all attempts failed, whether because
maxAttemptswas reached or the overall deadline elapsed. Carries: timestamp, name, total attempts, the last exception. - Interrupted — the thread was interrupted while waiting for a backoff delay. Carries: timestamp, name, attempt number, the last real failure (not the interrupt itself).
Throws RetryExhaustedException when the attempt budget or overall deadline runs out — produced from
the Exhausted path. Throws RetryRejectedException when shouldRetry declines to retry —
produced from the Rejected path. Throws RetryInterruptedException when the thread is interrupted
during a backoff wait — produced from the Interrupted path. All three carry attemptCount (total
attempts made, including the first) and the relevant failure via getCause(); only call()
throws them — outcome() instead returns a Failure outcome wrapping the same cause.