A failure anywhere in the chain short-circuits the remaining then* stages and propagates as a CompletionException. You intercept it with exceptionally (recover only on error), handle (transform result or error into a new result), or whenComplete (observe both without changing them).
How failure propagates
If any stage throws, the future completes exceptionally. Every downstream thenApply/thenCompose is skipped, and the exception flows down until a handler catches it. Note it is wrapped: the original cause becomes the getCause() of a CompletionException.
exceptionally — recover on error only
Runs only if the stage failed. It maps the throwable to a fallback value of the same type; on success it is transparent.
CompletableFuture<Price> price =
fetchPrice()
.exceptionally(ex -> { // only fires on failure
log.warn("price lookup failed", ex);
return Price.DEFAULT; // recovery value
});
Java 12 added exceptionallyCompose for an async fallback (returning a future) and exceptionallyAsync to run recovery on a pool.
handle — always runs, sees both
handle is invoked whether the stage succeeded or failed; it receives (result, throwable) where exactly one is non-null. It can swallow, rethrow, or transform.
CompletableFuture<String> result =
callService()
.handle((value, ex) -> {
if (ex != null) return "fallback:" + ex.getMessage();
return "ok:" + value;
});
Use handle when you want a single place to fold success and failure into one value.
whenComplete — observe, don't transform
whenComplete also sees (result, throwable) but its return is ignored — it cannot change the outcome. It is for side effects (logging, metrics, cleanup). If whenComplete itself throws, that new exception replaces the original.
callService()
.whenComplete((value, ex) -> {
if (ex != null) metrics.increment("service.error");
timer.stop(); // result is unchanged
});
Need to recover only on error, same type? -> exceptionally Need one value from success OR failure? -> handle Just logging/metrics, keep the outcome? -> whenComplete