How to Implement Retry Logic with CompletableFuture
Structured Java interview answer with junior, middle, and senior-level explanation.
🟢 Junior Level
Retry — retry an operation on failure. With CompletableFuture, this can be done via exceptionally():
public CompletableFuture<String> withRetry(Supplier<CompletableFuture<String>> operation, int maxRetries) {
return operation.get()
.exceptionallyCompose(ex -> {
if (maxRetries > 0) {
return withRetry(operation, maxRetries - 1);
}
return CompletableFuture.failedFuture(new RuntimeException("All retries failed", ex));
});
}
// Important: .join() inside exceptionally blocks the callback thread.
// exceptionallyCompose (Java 12+) preserves the non-blocking nature.
// Usage
withRetry(() -> fetchDataAsync(), 3)
.thenAccept(result -> System.out.println(result));
🟡 Middle Level
With Exponential Backoff
// CompletableFuture.delayedExecutor is available since Java 9.
// exceptionallyCompose is available since Java 12.
// For Java 8, use ScheduledExecutorService.
public CompletableFuture<String> withRetryAndDelay(
Supplier<CompletableFuture<String>> operation,
int maxRetries,
long delayMs
) {
return operation.get()
.exceptionallyCompose(ex -> {
if (maxRetries > 0) {
// Wait and retry
return CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
.thenCompose(v -> withRetryAndDelay(operation, maxRetries - 1, delayMs * 2));
}
return CompletableFuture.failedFuture(ex);
});
}
// Usage
withRetryAndDelay(() -> httpClient.getAsync(url), 3, 1000)
.thenAccept(System.out::println);
With Error Type Checking
public CompletableFuture<String> withRetryIf(
Supplier<CompletableFuture<String>> operation,
int maxRetries,
Predicate<Throwable> shouldRetry
) {
return operation.get()
.exceptionallyCompose(ex -> {
if (maxRetries > 0 && shouldRetry.test(ex)) {
return withRetryIf(operation, maxRetries - 1, shouldRetry);
}
return CompletableFuture.failedFuture(ex);
});
}
// Only for timeout
withRetryIf(() -> fetchDataAsync(), 3,
ex -> ex.getCause() instanceof TimeoutException);
Common Mistakes
- Infinite retry: ```java // ❌ No decrement of maxRetries .exceptionallyCompose(ex -> withRetry(operation, maxRetries));
// ✅ Decrement .exceptionallyCompose(ex -> withRetry(operation, maxRetries - 1));
---
## 🔴 Senior Level
### Resilience4j Integration
```java
import io.github.resilience4j.retry.Retry;
Retry retry = Retry.of("fetchData", RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofSeconds(1))
.retryOnException(ex -> ex instanceof TimeoutException)
.build());
CompletableFuture<String> result = Retry.decorateCompletionStage(
retry,
() -> fetchDataAsync()
);
Java 12+ exceptionallyCompose
cf.exceptionallyCompose(ex -> {
if (shouldRetry(ex)) {
return retryOperation();
}
return CompletableFuture.failedFuture(ex);
});
Production Experience
Generic retry utility:
public class RetryUtil {
public static <T> CompletableFuture<T> withRetry(
Supplier<CompletableFuture<T>> operation,
int maxRetries,
long initialDelayMs,
double backoffMultiplier
) {
return attempt(operation, maxRetries, initialDelayMs, backoffMultiplier);
}
private static <T> CompletableFuture<T> attempt(
Supplier<CompletableFuture<T>> operation,
int remaining,
long delayMs,
double multiplier
) {
return operation.get()
.exceptionallyCompose(ex -> {
if (remaining <= 0) {
return CompletableFuture.failedFuture(ex);
}
log.warn("Retry, {} attempts left", remaining - 1);
return CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
.thenCompose(v ->
attempt(operation, remaining - 1,
(long)(delayMs * multiplier), multiplier)
);
});
}
}
// Usage
RetryUtil.withRetry(
() -> httpClient.getAsync(url),
3,
1000,
2.0 // exponential backoff
// Pure exponential backoff causes "thundering herd" — all retry requests
// arrive simultaneously. In production, add jitter:
// delayMs * (1 + random.nextDouble() * 0.5)
)
.thenAccept(System.out::println);
Best Practices
// ✅ Limit the number of attempts
withRetry(operation, 3);
// ✅ Exponential backoff
delayMs * Math.pow(2, attempt);
// ✅ Error type checking
ex -> ex.getCause() instanceof TimeoutException;
// ❌ Infinite retry
// ❌ Retry for all errors (doesn't make sense for all of them)
🎯 Interview Cheat Sheet
Must know:
- exceptionallyCompose (Java 12+) — returns another CF on error, foundation for retry
- Exponential backoff: delayMs * multiplier^attempt, with jitter for production
- Attempt limit: maxRetries decrements by 1 each time
- Error type checking: retry only for retryable errors (Timeout, 5xx), not for all
- Resilience4j — production-ready solution with Circuit Breaker + Retry + Bulkhead
Frequent follow-up questions:
- exceptionally vs exceptionallyCompose for retry? — exceptionally returns a value, compose returns a CF (async retry)
- Why jitter in backoff? — Without jitter, all retry requests arrive simultaneously (thundering herd)
- Does delayedExecutor block a thread? — No, it’s a ScheduledExecutor. The thread is not blocked during the wait
- Java 8 retry without exceptionallyCompose? — Recursion in exceptionally: return withRetry(operation, retries - 1)
Red flags (DO NOT say):
- “Infinite retry is fine” — cascade failure on permanent error
- “Retry for all errors” — 4xx should not be retried, only 5xx and timeout
- “delayedExecutor blocks the callback thread” — it’s a scheduler, it doesn’t block
Related topics:
- [[6. How to Handle Exceptions in a CompletableFuture Chain]]
- [[7. What is the Difference Between handle(), exceptionally(), and whenComplete()]]
- [[20. How to Implement a Timeout for a CompletableFuture]]
- [[16. How to Properly Execute Multiple Parallel Requests to Microservices]]