Question 28 · Section 19

How to Implement Retry Logic with CompletableFuture

Structured Java interview answer with junior, middle, and senior-level explanation.

Language versions: English Russian Ukrainian

🟢 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

  1. 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]]