Retry Pattern An Example
The retry pattern converts a transient failure into an eventual success by re-invoking the exact same call after a pause that grows with each attempt and is randomized, but only while the error still looks transient and the attempt budget is not yet spent. Below is a framework-free Java utility that captures the whole mechanism: a bounded loop, exponential backoff, full jitter, error filtering, and correct interrupt handling.
import java.util.concurrent.Callable;
import java.util.concurrent.ThreadLocalRandom;
public final class RetryUtil {
/** Absolute ceiling on any single wait, so exponential growth can't explode. */
private static final long MAX_DELAY_MILLIS = 30_000;
/**
* Retry {@code task} up to {@code maxAttempts} times using exponential
* backoff with optional full jitter.
*
* @param task operation to run (returns T or throws)
* @param maxAttempts total tries: 1 initial + (maxAttempts-1) retries; must be >= 1
* @param baseDelayMillis delay before the first retry
* @param backoffFactor multiplier per retry (2.0 = double); 1.0 = constant delay
* @param useJitter if true, wait a UNIFORM random value in [0, computedDelay]
* @return the task's result on the first successful attempt
* @throws Exception the last failure if every attempt fails
*/
public static <T> T executeWithRetry(Callable<T> task, int maxAttempts,
long baseDelayMillis, double backoffFactor,
boolean useJitter) throws Exception {
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be >= 1");
}
Exception lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return task.call(); // success: return immediately
} catch (Exception e) {
lastException = e;
if (!isRetryable(e) || attempt == maxAttempts) {
break; // non-transient, or out of tries
}
}
// Exponential backoff, capped so it can't grow without bound.
long computed = (long) (baseDelayMillis * Math.pow(backoffFactor, attempt - 1));
long delay = Math.min(computed, MAX_DELAY_MILLIS);
if (useJitter) {
// FULL JITTER: sleep a uniform random value in [0, delay].
delay = ThreadLocalRandom.current().nextLong(delay + 1);
}
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt(); // preserve interrupt for callers
break; // stop retrying promptly
}
}
throw lastException; // non-null here: we only reach this after a caught failure
}
/** Retry only transient faults; let programming/auth errors fail fast. */
private static boolean isRetryable(Exception e) {
// Real code matches timeouts, connection resets, HTTP 5xx --
// NOT validation, auth, or 4xx client errors.
return !(e instanceof IllegalArgumentException
|| e instanceof SecurityException);
}
private RetryUtil() {}
}The five parameters are the retry policy. maxAttempts is the total tries including the first (so 3 means 1 try + 2 retries); baseDelayMillis is the wait before the first retry; backoffFactor is the per-retry multiplier (2.0 doubles, 1.0 holds a constant delay); useJitter randomizes the wait to break up synchronized retry bursts. Three changes make this version robust rather than merely illustrative: a MAX_DELAY_MILLIS cap so exponential growth cannot balloon into a minutes-long sleep, an isRetryable filter so a bug or a 401 fails fast instead of being retried pointlessly, and ThreadLocalRandom instead of a shared Random under contention.
Why the naive version is wrong
A naive loop typically (a) catches Throwable and retries everything — including NullPointerException and auth failures that will never recover; (b) has no delay cap, so base=1s, factor=2, attempts=10 ends with a single 512-second sleep; (c) swallows InterruptedException silently, so shutdown or cancellation can't stop it; and (d) describes full-jitter waits as if they were the fixed exponential ceilings. With full jitter each wait is a uniform random draw in [0, ceiling], not the ceiling itself — the code and the narration must agree on that.
A concrete trace
Call it exactly as the guide's usage example does — executeWithRetry(() -> fetchData(url), 3, 1000, 2.0, true) — and suppose the first two calls throw TimeoutException and the third succeeds. Because useJitter=true, each wait is a fresh uniform draw between 0 and that attempt's exponential ceiling. One representative run:
| attempt | task.call() | attempt == max (3)? | ceiling = 1000·2^(n−1) | full-jitter wait (this run) | action |
|---|---|---|---|---|---|
| 1 | throws TimeoutException | no | 1000·2⁰ = 1000 ms | rand[0,1000] → 640 ms | sleep 640, attempt→2 |
| 2 | throws TimeoutException | no | 1000·2¹ = 2000 ms | rand[0,2000] → 1310 ms | sleep 1310, attempt→3 |
| 3 | returns "OK" | — | — | — | return "OK" |
Total wait this run ≈ 640 + 1310 = 1950 ms. A different run of the same code might wait 120 ms then 1900 ms, or 900 ms then 40 ms — the totals differ every time.
This is the correction the page needed. With useJitter=true you cannot say "waits 1s then 2s" or "about 3 seconds of total wait" as if those were fixed — those figures are only the no-jitter ceilings. Full jitter shifts every wait to a uniform draw below its ceiling, so the honest statement is: the total wait is random, averages roughly half the no-jitter total (~1.5 s here), and is bounded above by ~3 s. That randomness is the entire point: it desynchronizes thousands of clients that all failed at the same instant, spreading their retries across the interval instead of firing them in one thundering herd.
Pitfalls
- Retrying non-idempotent operations. A payment or "send email" that timed out after the server processed it will be executed again — a double charge. Retry only idempotent calls, or attach an idempotency key the server dedupes on.
- Retrying non-transient errors. Retrying a
400, a validation failure, or an auth error just burns latency and load for a result that will never change. That is exactly whatisRetryableguards against. - Retry amplification across layers. If the gateway retries 3×, which calls a service that retries 3×, which calls a DB client that retries 3×, one user request can become 27 calls to a struggling backend. Retry at one layer, not every layer.
- No delay cap = runaway sleeps. Uncapped exponential backoff (
base=1s, factor=2) reaches an 8-minute single wait by attempt 10. Cap it (here 30 s) so a retry can't outlive the caller's own timeout. - Blocking a pooled thread.
Thread.sleepparks the calling thread. Under load, hundreds of threads sleeping in backoff can exhaust a request pool and take the whole service down. For high-concurrency paths use non-blocking/scheduled retries instead of a blocking sleep. - Swallowing
InterruptedException. If you catch it and keep looping, shutdown and cancellation can't stop the retries. Restore the flag (Thread.currentThread().interrupt()) and bail — as the code does.
When to use it, and when not
Reach for retry when the failure is transient and independent of your input — a dropped connection, a momentary timeout, a leader election, an HTTP 503/429 — and the operation is idempotent (or made idempotent with a dedupe key). Concrete signal: the same call, unchanged, tends to succeed seconds later.
Avoid it when the operation has non-idempotent side effects with no dedupe, when the error is a permanent client error (retrying can't fix a 404), or when the downstream is already overloaded — there, more retries deepen the outage.
Trade-offs vs. alternatives
- vs. Circuit Breaker. Retry assumes the fault is brief and worth waiting out; it keeps sending traffic. A circuit breaker assumes the fault is sustained and stops sending traffic entirely once failures cross a threshold, giving the downstream room to recover and failing fast for the caller. They are complementary: retry the individual blip, but wrap it in a breaker so a persistent outage trips the breaker instead of amplifying load. Choose retry alone when failures are rare and isolated; add a breaker once failures cluster or the downstream can be overwhelmed.
- vs. fail-fast (no retry). Retry buys higher success rates at the cost of added tail latency (the user waits through every backoff) and extra load. Fail-fast gives predictable low latency and zero amplification but surfaces every transient blip to the user. Prefer fail-fast on user-facing synchronous paths with tight SLAs where a stale-but-instant response beats a slow-correct one.
- vs. async queue + dead-letter. For work that doesn't need an immediate answer, enqueue it and let a worker retry with backoff, moving poison messages to a dead-letter queue. You gain durability and decoupling (the caller returns instantly) at the cost of eventual-consistency and queue infrastructure. Prefer this for background jobs, webhooks, and anything tolerant of delay.
Takeaways
- The mechanism is a bounded loop: call → on transient failure, wait
min(base·factor^(n−1), cap)→ retry until success or the attempt budget is spent, then rethrow. - Full jitter replaces the fixed backoff with a uniform draw in
[0, ceiling]— so state totals as random and bounded above, never as exact 1s/2s/3s figures. - Two guards separate a toy from production: an
isRetryablefilter (never retry 4xx/bugs/non-idempotent writes) and a delay cap plus interrupt-aware sleep. - Retry handles the brief blip; pair it with a circuit breaker for sustained outages and prefer async queues for work that can wait.
Re-authored and deepened for this guide. Jitter and backoff strategy follow the AWS Architecture Blog, "Exponential Backoff And Jitter" (Marc Brooker); pattern context and trade-offs draw on Michael Nygard, Release It! (2nd ed.) and Chris Richardson, microservices.io — Retry and Circuit Breaker patterns. Interrupt-handling guidance follows Brian Goetz et al., Java Concurrency in Practice.
🤖 Don't fully get this? Learn it with Claude
Stuck on Retry Pattern An Example? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Retry Pattern An Example** (System Design) and want to truly understand it. Explain Retry Pattern An Example from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Retry Pattern An Example** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Retry Pattern An Example** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Retry Pattern An Example** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.