A RetryPolicy interface with FixedDelay and ExponentialBackoff implementations, why jitter matters under concurrent load, and composing retry logic cleanly with a circuit breaker.
Published September 23, 2026
interface RetryPolicy {
boolean shouldRetry(int attemptNumber, Exception lastError);
Duration nextDelay(int attemptNumber);
}
class FixedDelayRetry implements RetryPolicy {
Duration delay; int maxAttempts;
public boolean shouldRetry(int attempt, Exception e) { return attempt < maxAttempts; }
public Duration nextDelay(int attempt) { return delay; }
}
class ExponentialBackoffRetry implements RetryPolicy {
Duration baseDelay; int maxAttempts; double multiplier;
public boolean shouldRetry(int attempt, Exception e) { return attempt < maxAttempts; }
public Duration nextDelay(int attempt) {
return baseDelay.multipliedBy((long) Math.pow(multiplier, attempt));
}
}
Separating shouldRetry (whether to retry at all — could also inspect the EXCEPTION TYPE, since a 4xx client error usually shouldn't be retried while a 5xx or timeout should) from nextDelay (how long to wait) as two distinct interface methods keeps the POLICY DECISION cleanly separable from the TIMING calculation — a policy could retry on some exceptions but not others without touching the delay-calculation logic at all.
class JitteredExponentialBackoff implements RetryPolicy {
ExponentialBackoffRetry base;
public Duration nextDelay(int attempt) {
Duration exact = base.nextDelay(attempt);
long jitterMs = ThreadLocalRandom.current().nextLong(0, exact.toMillis());
return Duration.ofMillis(jitterMs); // full jitter: random value between 0 and the exact backoff
}
}
Without jitter, MANY clients that all failed at roughly the same moment (a brief downstream outage affecting every caller simultaneously) will all retry at EXACTLY the same computed delay — producing a synchronized retry storm that can overwhelm the recovering dependency right as it comes back up, potentially causing it to fail again immediately. Adding randomization ("jitter") to the delay spreads retries out over time instead of a single synchronized spike — this is a genuinely important, easy-to-miss detail: exponential backoff alone solves the "don't hammer immediately" problem, but only jitter solves the "don't hammer all at exactly the same instant" problem.
class ResilientCaller {
RetryPolicy retryPolicy;
CircuitBreaker circuitBreaker; // from Design a Circuit Breaker
<T> T call(Supplier<T> operation) {
int attempt = 0;
while (true) {
try {
return circuitBreaker.execute(operation); // circuit breaker wraps the actual call
} catch (Exception e) {
attempt++;
if (!retryPolicy.shouldRetry(attempt, e) || circuitBreaker.isOpen()) throw e;
sleep(retryPolicy.nextDelay(attempt));
}
}
}
}
Retry and circuit breaker are COMPLEMENTARY, not redundant — retry handles a single call's transient failure (worth trying again); the circuit breaker tracks the AGGREGATE failure rate across many calls and stops trying entirely once a dependency is clearly down, preventing retries themselves from becoming part of the overload problem. The composed caller checks circuitBreaker.isOpen() before continuing to retry — once the breaker trips, further retries are abandoned immediately rather than continuing to hammer a dependency the breaker has already determined is unhealthy.
Q: Should shouldRetry() ever depend on WHAT the operation actually is, not just the exception? A: Yes for non-idempotent operations specifically — retrying a network timeout is safe for a read, but retrying a WRITE that might have already succeeded on the far end (the Payment — Requirements 'no true undo' problem) needs the operation itself to be idempotent (Payment — Idempotency Implementation) before blind retrying is safe at all; the retry mechanism's correctness depends on this being true of the wrapped operation, not something it can enforce itself.
Q: Is full jitter (0 to the full backoff value) always the best jitter strategy? A: There are variants (e.g. 'equal jitter,' splitting the delay into a fixed half plus a random half) that trade off between retry-storm avoidance and average latency — full jitter maximizes spread but can occasionally produce a very short delay right after a failure; the right variant depends on how aggressively you want to avoid synchronized retries vs minimize average recovery latency.
Q: How many max attempts is reasonable before giving up entirely? A: There's no universal number — it should be tuned against the operation's actual timeout budget (Timeout Strategy) and how quickly the caller's own SLA requires a definitive answer; a request with a tight end-to-end latency budget can only afford 1-2 retries with short backoff, while a background job can reasonably retry many more times over a longer window.
Q: Does this retry design interact with the bulkhead pattern from the Resilience Patterns chapter? A: Yes — retries INCREASE the total number of in-flight/attempted calls to a struggling dependency, which is exactly the kind of resource consumption Bulkhead Pattern exists to isolate and cap; a retry mechanism without a bulkhead limiting concurrent calls can itself contribute to resource exhaustion, even with jitter smoothing out the TIMING of those retries.