When an exception is thrown inside a CompletableFuture stage, how does it propagate down the chain? What is CompletionException and where does it come from?
answer
- Throw -> stage completes exceptionally -> normal stages skipped
- Dependent stages wrap cause in CompletionException
- Originating stage holds the raw exception
- Always unwrap via getCause() in handlers
- CompletionException is unchecked
basics
~10 sIf a stage throws, the future completes exceptionally and later stages are skipped until an error-handling callback. The original exception is usually wrapped in a CompletionException, so you often unwrap it via getCause().
solid answer
~40 sWhen the function inside a stage throws, that stage completes exceptionally with the thrown Throwable. Downstream dependent stages (thenApply, thenCompose, etc.) are then skipped and the failure short-circuits to the first error-aware callback (exceptionally/handle/whenComplete). The key subtlety: as the failure flows to *dependent* stages, the runtime wraps the original cause in an unchecked CompletionException. So inside an exceptionally on a downstream stage, the Throwable you receive may be a CompletionException whose getCause() is your real exception. The stage that *originally* threw sees the raw exception; dependents see it wrapped. This wrapping is why robust error handlers unwrap: if (ex instanceof CompletionException) ex = ex.getCause(). It mirrors how join() throws CompletionException — the wrapping is consistent across the propagation and the blocking accessor.
code
java · 8 linesCompletableFuture.supplyAsync(() -> { throw new IllegalArgumentException("bad"); })
.thenApply(v -> v + "!") // skipped on failure
.exceptionally(ex -> {
Throwable cause = (ex instanceof CompletionException)
? ex.getCause() : ex; // unwrap before inspecting
System.out.println(cause.getClass()); // IllegalArgumentException
return "fallback";
});go deeper
Knows that a thrown exception makes the future 'fail' and that later steps don't run; may not mention wrapping.
Explains short-circuit propagation past normal stages and that the cause is wrapped in CompletionException, so handlers unwrap via getCause().
Distinguishes the raw exception at the originating stage from the wrapped one downstream, knows the no-double-wrap rule, and writes unwrap-normalizing handlers.
Designs a consistent exception-normalization layer (helper to unwrap), reasons about cancellation vs failure surfacing, and standardizes error semantics across an async codebase.
## The model A `CompletableFuture` stage holds a function. When you run that function and it throws, the stage doesn't crash the program — it captures the `Throwable` and becomes **exceptionally completed**. The whole point of `CompletableFuture` is composition, so the interesting question is: what happens to the *chain* of stages built on top of a failing one? ## Short-circuit propagation Dependent stages created with `thenApply`, `thenAccept`, `thenCompose`, `thenCombine`, etc. only run on **normal** completion of their input. If the input completed exceptionally, these stages are **skipped** and *also* complete exceptionally with the same failure. The error therefore "short-circuits" past all the normal-path stages until it reaches an error-aware stage: `exceptionally`, `handle`, or `whenComplete`. This is analogous to an exception unwinding past statements until a `catch`. ```java cf.thenApply(this::step1) // skipped if cf failed .thenApply(this::step2) // skipped .exceptionally(ex -> { // <-- first to actually run on failure // ex may be a CompletionException wrapping the real cause return fallback(); }); ``` ## What is CompletionException? `CompletionException` is an **unchecked** (`RuntimeException`) wrapper introduced for `CompletableFuture`. When a failure propagates to a **dependent** stage, the framework wraps the original cause in a `CompletionException` (unless the cause is *already* a `CompletionException`, in which case it isn't double-wrapped). So: - The stage whose function **directly threw** completes with the **raw** exception (e.g. `IllegalStateException`). - Stages **downstream** of it that inherit the failure see it wrapped: `CompletionException(cause = IllegalStateException)`. Why wrap at all? Because `CompletableFuture` callbacks are functional (they can't declare `throws SomeCheckedException`), the API needs a single unchecked carrier to move *any* throwable — including checked ones — through the lambda-based pipeline. `CompletionException` is that carrier for the *dependency/propagation* path (and for `join()`); `ExecutionException` plays the analogous role for `get()`. ## Practical consequence: always unwrap Because the wrapping depends on whether you're at the originating stage or a dependent one, robust handlers normalize: ```java .exceptionally(ex -> { Throwable cause = (ex instanceof CompletionException && ex.getCause() != null) ? ex.getCause() : ex; if (cause instanceof TimeoutException) { ... } return fallback(); }); ``` If you skip the unwrap, an `instanceof` check against your domain exception may silently fail because you're actually holding a `CompletionException`. ## Edge cases - If the thrown exception is already a `CompletionException`, it is **not** re-wrapped. - `whenComplete` and `handle` receive the same (possibly wrapped) throwable that propagated to them. - Cancellation surfaces as `CancellationException` (a `CancellationException`, not wrapped in `CompletionException` for `join()` — it's thrown directly). ## Mental model Think of the failure as a baton: the runner who *drops* it (the throwing stage) holds the raw exception; every runner it's *handed to* afterward receives it inside a `CompletionException` envelope. Unwrap the envelope before inspecting the baton.
- Does CompletableFuture ever double-wrap into CompletionException(CompletionException(...))?No. If the propagating cause is already a CompletionException, it is not wrapped again; the existing CompletionException is reused.
- Why must this wrapper be unchecked?CompletableFuture's callbacks are functional interfaces that cannot declare checked exceptions, so any failure (including checked ones) must be carried by an unchecked RuntimeException — CompletionException.
saying these in an interview costs you the question
- Doing instanceof on the domain exception without unwrapping CompletionException first
- Believing the original throwing stage also wraps the exception (it holds the raw one)
- Thinking downstream thenApply stages still run after a failure
- Confusing CompletionException (propagation/join) with ExecutionException (get)