Question 6 · Section 19

How to handle exceptions in CompletableFuture chain

There are 3 main ways to handle exceptions in CompletableFuture:

Language versions: English Russian Ukrainian

🟢 Junior Level

There are 3 main ways to handle exceptions in CompletableFuture:

  1. exceptionally() — return a default value on error
  2. handle() — handle both result and error
  3. whenComplete() — perform an action on completion (does not change the result)
// 1. exceptionally — fallback value
CompletableFuture.supplyAsync(() -> {
    throw new RuntimeException("Error");
}).exceptionally(ex -> "default value");  // will return "default value"

// 2. handle — handles both success and error
CompletableFuture.supplyAsync(() -> {
    throw new RuntimeException("Error");
}).handle((result, ex) -> {
    if (ex != null) return "error occurred";
    return result;
});

// 3. whenComplete — just learn about completion
CompletableFuture.supplyAsync(() -> "Hello")
    .whenComplete((result, ex) -> {
        if (ex != null) {
            System.err.println("Error: " + ex);
        } else {
            System.out.println("Result: " + result);
        }
    });

🟡 Middle Level

Detailed comparison

exceptionally — like try-catch:

CompletableFuture<String> cf = fetchDataAsync()
    .exceptionally(ex -> {
        log.error("Fetch failed", ex);
        return "default";  // fallback value
    });

handle — full control:

CompletableFuture<String> cf = fetchDataAsync()
    .handle((result, ex) -> {
        if (ex != null) {
            log.error("Failed", ex);
            return "error";
        }
        return result.toUpperCase();  // can modify the result
    });

whenComplete — side effect:

CompletableFuture<String> cf = fetchDataAsync()
    .whenComplete((result, ex) -> {
        // Does not change the result — only side effect
        if (ex == null) {
            metrics.increment("success");
        } else {
            metrics.increment("failure");
        }
    })
    .thenApply(s -> s.toUpperCase());  // result is unchanged

Typical mistakes

  1. exceptionally CATCHES exceptions from thenApply: ```java // ✅ Exception in thenApply will be caught by downstream exceptionally cf.thenApply(s -> { throw new RuntimeException(“Error”); }).exceptionally(ex -> “default”); // ✅ OK, catches it

// ✅ But handle is better for full control cf.handle((result, ex) -> { if (ex != null) return “default”; return result; });


2. **CompletionException wrapping:**
```java
// Exceptions are wrapped in CompletionException
cf.exceptionally(ex -> {
    // ex — CompletionException, not the original exception!
    Throwable cause = ex.getCause();  // original
    return "default";
});

🔴 Senior Level

Internal Implementation

exceptionally:

public CompletableFuture<T> exceptionally(Function<Throwable, ? extends T> fn) {
    return uniExceptioniallyStage(fn);
}

// Called only on error
// If success — fn is not called
// Returns a fallback value

handle:

public <U> CompletableFuture<U> handle(BiFunction<? super T, Throwable, ? extends U> fn) {
    return uniHandleStage(null, fn);
}

// Called ALWAYS — both on success and error
// result — value (null on error)
// ex — exception (null on success)

whenComplete:

public CompletableFuture<T> whenComplete(BiConsumer<? super T, ? super Throwable> action) {
    return uniWhenCompleteStage(null, action);
}

// Called ALWAYS
// Does NOT change the result — just a side effect
// Exception from action is wrapped

Architectural Trade-offs

Method Always called Changes result Catches errors
exceptionally ❌ Only on error ✅ Fallback
handle ✅ Full control
whenComplete ❌ Side effect only

Edge Cases

1. Multiple error handlers:

cf.exceptionally(ex -> {
    log.error("First handler", ex);
    return "first";
}).exceptionally(ex -> {
    // Not called — the first one already handled
    return "second";
});

2. Exception in handler:

cf.exceptionally(ex -> {
    throw new RuntimeException("Handler error");  // New exception
}).exceptionally(ex -> {
    // Will catch the exception from the first handler
    return "recovered";
});

3. handle for transformation:

// handle can change the type
cf.<String>handle((result, ex) -> {
    if (ex != null) {
        return "Error: " + ex.getMessage();
    }
    return "Success: " + result;
});

Performance

exceptionally: ~5 ns (only on error)
handle: ~10 ns (always)
whenComplete: ~8 ns (always)

Overhead is negligible compared to async operations

Production Experience

Retry logic:

public <T> CompletableFuture<T> withRetry(
    Supplier<CompletableFuture<T>> operation,
    int maxRetries
) {
    return operation.get()
        .exceptionallyCompose(ex -> {
            if (maxRetries > 0 && isRetryable(ex)) {
                log.warn("Retry after error", ex);
                // In production, add a delay:
                return CompletableFuture.delayedExecutor(100, MILLISECONDS)
                    .thenCompose(v -> withRetry(operation, maxRetries - 1));
            }
            return CompletableFuture.failedFuture(ex);
        });
}

// Java 12+
cf.exceptionallyCompose(ex -> {
    if (shouldRetry(ex)) {
        return retryOperation();
    }
    return CompletableFuture.failedFuture(ex);
});

Circuit breaker pattern:

public CompletableFuture<Response> callWithCircuitBreaker(Request req) {
    if (circuitBreaker.isOpen()) {
        return CompletableFuture.failedFuture(
            new CircuitBreakerOpenException());
    }

    return callAsync(req)
        .handle((result, ex) -> {
            if (ex != null) {
                circuitBreaker.recordFailure();
                throw new CompletionException(ex);
            }
            circuitBreaker.recordSuccess();
            return result;
        });
}

Best Practices

// ✅ exceptionally for fallback
cf.exceptionally(ex -> defaultValue);

// ✅ handle for full control
cf.handle((result, ex) -> ex != null ? fallback : result);

// ✅ whenComplete for monitoring
cf.whenComplete((r, ex) -> metrics.record(r, ex));

// ❌ Ignoring errors
// ❌ Blocking operations in handlers
// ❌ Creating new exceptions without reason

🎯 Interview Cheat Sheet

Must know:

  • exceptionally(Function<Throwable, T>) — fallback on error, like catch
  • handle(BiFunction<T, Throwable, U>) — always called, full control
  • whenComplete(BiConsumer<T, Throwable>) — side effect only, does not change the result
  • Exceptions are wrapped in CompletionException, original via getCause()
  • exceptionallyCompose (Java 12+) — returns another CF on error (for retry)

Frequent follow-up questions:

  • Does whenComplete catch exceptions? — No, it only observes. The exception remains in the CF
  • Is exceptionally called on success? — No, only on error
  • How to do retry? — exceptionallyCompose (Java 12+) or recursion in exceptionally
  • Can you handle an error in multiple handlers? — Yes, but the first one that catches it will consume it

Red flags (DO NOT say):

  • “whenComplete catches exceptions” — it only observes, does not intercept
  • “exceptionally is always called” — only on error, handle is always called
  • “Exception in handler won’t be handled” — handler exceptions are also caught by downstream exceptionally

Related topics:

  • [[7. What is the difference between handle(), exceptionally() and whenComplete()]]
  • [[18. What happens if an exception occurs in CompletableFuture chain]]
  • [[27. How to implement retry logic with CompletableFuture]]
  • [[1. What is CompletableFuture and how does it differ from Future]]