How to implement retry logic with CompletableFuture
In CompletableFuture, retry is achieved by handling failures and re-triggering the asynchronous task supplier. Starting in Java 12, CompletableFuture provides the non-blocking e...
🟢 Junior Level
Retry is a fault-tolerance pattern that re-executes an asynchronous operation after a failure (such as a temporary network glitch or upstream microservice unavailability).
In CompletableFuture, retry is achieved by handling failures and re-triggering the asynchronous task supplier. Starting in Java 12, CompletableFuture provides the non-blocking exceptionallyCompose() method, which accepts the thrown exception and returns a new CompletableFuture:
public <T> CompletableFuture<T> withRetry(Supplier<CompletableFuture<T>> action, int retriesLeft) {
return action.get()
.exceptionallyCompose(throwable -> {
if (retriesLeft > 0) {
log.warn("Operation failed, retrying... Remaining attempts: {}", retriesLeft);
return withRetry(action, retriesLeft - 1); // Asynchronous re-trigger
}
// Retries exhausted — propagate original exception:
return CompletableFuture.failedFuture(throwable);
});
}
// Example usage:
withRetry(() -> paymentClient.chargeAsync(orderId), 3)
.thenAccept(receipt -> log.info("Payment success: {}", receipt));
Golden Rule
Never block thread pool workers with Thread.sleep() inside a CompletableFuture pipeline. Retries must be scheduled asynchronously to avoid worker thread exhaustion.
🟡 Middle Level
1. Adding Non-Blocking Backoff Delays
In distributed systems, immediate retries are counterproductive because a struggling downstream service needs time to recover. A delay between attempts is required.
In Java 9+, use CompletableFuture.delayedExecutor():
public <T> CompletableFuture<T> withRetryAndDelay(
Supplier<CompletableFuture<T>> action,
int retriesLeft,
long delayMillis,
ScheduledExecutorService scheduler
) {
return action.get()
.exceptionallyCompose(throwable -> {
if (retriesLeft <= 0) {
return CompletableFuture.failedFuture(throwable);
}
// Create a delayed executor that triggers the task after delayMillis:
Executor delayedExecutor = CompletableFuture.delayedExecutor(
delayMillis, TimeUnit.MILLISECONDS, scheduler
);
// Schedule the retry asynchronously without blocking:
return CompletableFuture.supplyAsync(action, delayedExecutor)
.thenCompose(Function.identity()) // Flatten CompletableFuture<CompletableFuture<T>>
.exceptionallyCompose(ex ->
withRetryAndDelay(action, retriesLeft - 1, delayMillis * 2, scheduler)
);
});
}
2. Error Classification and Idempotency
Only transient errors on idempotent operations should be retried:
- Retryable: Connection drops (
ConnectException), read timeouts (SocketTimeoutException), HTTP 503 (Service Unavailable), HTTP 429 (Too Many Requests). - Non-retryable: HTTP 400 (Bad Request), 401/403 (Security), 404 (Not Found), 422 (Unprocessable Entity), or non-idempotent operations lacking an idempotency key.
public boolean isRetryable(Throwable throwable) {
Throwable cause = (throwable instanceof CompletionException)
? throwable.getCause()
: throwable;
return cause instanceof TimeoutException
|| cause instanceof SocketException
|| (cause instanceof HttpResponseException http && http.getStatusCode() == 503);
}
🔴 Senior Level
1. Thundering Herd and Full Jitter
If a dependency fails while serving 10,000 concurrent clients, and all clients apply rigid exponential backoff ($1\text{s}, 2\text{s}, 4\text{s}$), all 10,000 clients will retry simultaneously in periodic synchronized waves (Retry Storm or Thundering Herd problem).
To prevent synchronized waves, introduce randomized delay: Full Jitter:
\[\text{sleep} = \text{random\_between}(0, \; \min(\text{max\_backoff}, \; \text{base\_delay} \times 2^{\text{attempt}}))\]public class BackoffUtil {
public static long calculateFullJitterDelay(int attempt, long baseDelayMs, long maxDelayMs) {
long exponentialLimit = Math.min(maxDelayMs, baseDelayMs * (1L << attempt));
return ThreadLocalRandom.current().nextLong(0, exponentialLimit + 1);
}
}
2. Production-Grade Non-Blocking Retry Utility
A robust non-blocking implementation for Java 12+:
public final class AsyncRetry {
private AsyncRetry() {}
public static <T> CompletableFuture<T> retry(
Supplier<CompletableFuture<T>> taskSupplier,
int maxAttempts,
Duration initialBackoff,
Duration maxBackoff,
Predicate<Throwable> retryPredicate,
ScheduledExecutorService scheduler
) {
return executeAttempt(taskSupplier, 0, maxAttempts, initialBackoff.toMillis(),
maxBackoff.toMillis(), retryPredicate, scheduler);
}
private static <T> CompletableFuture<T> executeAttempt(
Supplier<CompletableFuture<T>> taskSupplier,
int currentAttempt,
int maxAttempts,
long baseDelayMs,
long maxDelayMs,
Predicate<Throwable> retryPredicate,
ScheduledExecutorService scheduler
) {
return taskSupplier.get()
.exceptionallyCompose(ex -> {
int nextAttempt = currentAttempt + 1;
if (nextAttempt >= maxAttempts || !retryPredicate.test(ex)) {
return CompletableFuture.failedFuture(ex);
}
long delayMs = BackoffUtil.calculateFullJitterDelay(currentAttempt, baseDelayMs, maxDelayMs);
CompletableFuture<T> delayedPromise = new CompletableFuture<>();
scheduler.schedule(() -> {
executeAttempt(taskSupplier, nextAttempt, maxAttempts, baseDelayMs, maxDelayMs, retryPredicate, scheduler)
.whenComplete((res, error) -> {
if (error != null) delayedPromise.completeExceptionally(error);
else delayedPromise.complete(res);
});
}, delayMs, TimeUnit.MILLISECONDS);
return delayedPromise;
});
}
}
3. Enterprise Integration: Resilience4j
In production enterprise architectures, teams typically employ Resilience4j, combining Retry, CircuitBreaker, and TimeLimiter over CompletionStage:
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(500))
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(500, 2.0, 0.5)) // Exponential + Jitter
.retryExceptions(SocketTimeoutException.class, IOException.class)
.ignoreExceptions(IllegalArgumentException.class)
.build();
Retry retry = RetryRegistry.ofDefaults().retry("paymentRetry", config);
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
// Decorate the asynchronous stage:
Supplier<CompletionStage<PaymentResult>> decorated = Retry.decorateCompletionStage(
retry,
scheduler,
() -> paymentClient.chargeAsync(orderId)
);
CompletableFuture<PaymentResult> future = decorated.get().toCompletableFuture();
4 Tricky Questions
1. Does the recursive call in asynchronous withRetry cause a StackOverflowError?
Answer:
No, it does not.
Stack frames accumulate only in synchronous recursion where method invocations nest on the same thread stack. In asynchronous retry, each recursive iteration executes inside a deferred callback (exceptionallyCompose or scheduler.schedule).
By the time the callback fires, the preceding withRetry execution has already returned and its stack frame has been popped from the OS thread stack. Execution is dispersed across time and threads, keeping thread call stack depth constant ($O(1)$).
2. Why is Thread.sleep(delay) inside exceptionally() a severe anti-pattern, and what is the proper alternative?
Answer:
The exceptionally() lambda executes on a worker thread from the pool that finished the previous stage (often ForkJoinPool.commonPool() or Netty event-loop I/O threads).
Calling Thread.sleep(delay) blocks that worker thread. If dozens or hundreds of requests encounter transient failures and sleep for 1–2 seconds, all worker threads become blocked. This induces system-wide thread starvation, cascading request delays, and full outage. The non-blocking solution is scheduling delays via ScheduledExecutorService or CompletableFuture.delayedExecutor().
3. What is Full Jitter, and why is pure exponential backoff dangerous in distributed systems?
Answer:
When an upstream service or database recovers from an outage, hundreds of clients receive errors at roughly time $T_0$. Under pure exponential backoff, all clients wait exactly 1000 ms and resend requests simultaneously at $T_0 + 1000\text{ms}$. If rejected again, they all retry together at $T_0 + 3000\text{ms}$.
This is a Retry Storm (or Thundering Herd). Full Jitter randomizes intervals uniformly: random(0, exponentialDelay). This spreads retry spikes evenly across time, allowing the recovering service to warm up caches and absorb load gracefully.
4. How can asynchronous retry be implemented in Java 8 without exceptionallyCompose?
Answer:
Java 8’s exceptionally() takes a Function<Throwable, ? extends T> and can only return a direct value $T$, not a nested CompletableFuture<T>.
In Java 8, asynchronous retry is built using whenComplete and a manual promise:
public static <T> CompletableFuture<T> retryJava8(
Supplier<CompletableFuture<T>> action, int retries, ScheduledExecutorService scheduler
) {
CompletableFuture<T> promise = new CompletableFuture<>();
attemptJava8(action, retries, promise, scheduler);
return promise;
}
private static <T> void attemptJava8(
Supplier<CompletableFuture<T>> action, int retries,
CompletableFuture<T> promise, ScheduledExecutorService scheduler
) {
action.get().whenComplete((val, ex) -> {
if (ex == null) {
promise.complete(val);
} else if (retries > 0) {
scheduler.schedule(() -> attemptJava8(action, retries - 1, promise, scheduler), 1, TimeUnit.SECONDS);
} else {
promise.completeExceptionally(ex);
}
});
}
🎯 Interview Cheat Sheet
Summary in 60 Seconds
- In Java 12+, implement asynchronous retries non-blockingly using
.exceptionallyCompose(). - In Java 9+, use
CompletableFuture.delayedExecutor()for non-blocking backoff delays. - In Java 8, use
whenComplete()with a manualpromiseandScheduledExecutorService. - For enterprise services, use Resilience4j (
Retry.decorateCompletionStage). - Never use
Thread.sleep()inside asynchronous callbacks. - Always apply Full Jitter to exponential backoff to eliminate Thundering Herd retry storms.
- Only retry transient network errors (timeouts, 503, 429) on idempotent requests.
Common Interview Traps
- Using
Thread.sleep()inexceptionallyorhandle, freezing worker threads. - Unbounded retries without a maximum attempt cap.
- Retrying non-idempotent operations without an idempotency key, causing duplicate charges.