🔴 The Error You're Seeing

Confirm this matches your console output. If it does, you're in the right place.

ERROR LOGjava.util.concurrent.CompletionException: java.lang.IllegalStateException: stock reservation failed at java.base/java.util.concurrent.CompletableFuture.wrapInCompletionException(CompletableFuture.java:323) at java.base/java.util.concurrent.CompletableFuture.encodeThrowable(CompletableFuture.java:359) at java.base/java.util.concurrent.CompletableFuture.completeThrowable(CompletableFuture.java:364) at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1791) at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:2019) Caused by: java.lang.IllegalStateException: stock reservation failed at com.devinhyderabad.order.ReservationService.reserve(ReservationService.java:31) ... 5 more

⚡ Quick Fix Works 80% of the time

Read the Caused by — join() adds only the wrapper. Strip it once in a helper and handle the real type.

try { reservation.join(); } catch (CompletionException e) { Throwable real = e.getCause(); // IllegalStateException lives here if (real instanceof IllegalStateException ise) { rollbackCart(); throw ise; // domain type: rethrow directly } if (real instanceof RuntimeException re) { throw re; } throw new IllegalStateException(real); // checked causes need a wrapper }

🧠 Why this Happens

Tap to expand the deep technical explanation

When a supplyAsync lambda throws, AsyncSupply.run calls completeThrowable, which encodes the throwable and stores it as the future’s result field. join() observes exceptional state and passes the stored cause to wrapInCompletionException, producing an unchecked CompletionException so lambdas and streams need no throws clauses. get() takes the other branch, wrapping in checked ExecutionException — same stored cause, two envelopes depending on accessor.

The HITEC City Parking Spot Analogy:

A courier slip taped to your door says only “delivery problem” (the wrapper); the parcel’s packing list inside (the Caused by) names the broken item and who packed it.

🔁 How to Reproduce Confirm this is your error

CompletableFuture.supplyAsync(() -> { throw new IllegalStateException("stock reservation failed"); }).join() yields exactly this two-part trace. Replace join() with get() to see ExecutionException instead. (Lab capture: OpenJDK Temurin 25.0.2.)

🛠️ Solutions (5 Ways to Fix)

Solution 1✓ Most common cause

Strip the wrapper once in a shared unwrap helper

👉 Use this when/if joins appear across many services and each reinvents unwrapping.

A single utility walks getCause() while the current throwable is a CompletionException (or nested ExecutionException), returning the root. Handlers then switch on domain types consistently, and logs stop showing meaningless wrapper lines at the top.

static Throwable root(Throwable t) { while ((t instanceof CompletionException || t instanceof ExecutionException) && t.getCause() != null) { t = t.getCause(); } return t; }
Solution 2

Choose join inside composition, get at boundaries

👉 Use this when/if deciding between the two accessors feels arbitrary.

Inside stream maps and composed suppliers, checked exceptions cannot propagate, so join() (or the CE it throws) fits. At service boundaries where callers should consciously handle failure, get() forces the issue through checked ExecutionException. Consistency per layer prevents surprise wrappers leaking into APIs.

// internal composition — join is idiomatic var merged = futures.stream().map(CompletableFuture::join).toList(); // boundary — checked contract public Order place(Order o) throws ExecutionException, InterruptedException { return pipeline.apply(o).get(2, TimeUnit.SECONDS); }
Solution 3

Normalize failures with exceptionally/handle at the source

👉 Use this when/if consumers should see domain exceptions, not framework wrappers.

Attaching .exceptionally right where the work happens translates any raw throwable into a meaningful domain exception once. Downstream joins then surface PaymentFailedException directly, and the wrapper never travels further than one stage.

CompletableFuture<Reservation> guarded = reserveAsync(cart) .exceptionally(ex -> { if (root(ex) instanceof InventoryConflict ic) { throw new ReservationFailed(ic); } throw ex instanceof RuntimeException re ? re : new IllegalStateException(ex); });
Solution 4

Log with whenComplete without altering the result

👉 Use this when/if observability must not change control flow.

whenComplete sees the outcome — null throwable on success, real cause on failure — and passes everything through untouched. Wiring it at pipeline creation gives centralized structured logging of every async failure with correlation ids, independent of however callers consume results.

reserveAsync(cart) .whenComplete((res, ex) -> { if (ex != null) audit.failure(cart.id(), root(ex)); else audit.success(cart.id(), res); });
Solution 5

DEV ONLY: catch CompletionException globally and continue

👉 Use this only in fault-injection experiments — swallowing wrappers in production strands half-finished workflows.

A blanket catch treats inventory conflicts, NPEs, and cancellations identically: nothing retries, nothing alerts, and the workflow stalls in a half-mutated state. The wrapper exists precisely so someone inspects the cause.

// ANTI-PATTERN — do not ship try { reserveAsync(cart).join(); } catch (CompletionException ignored) { } // cart mutated, order lost

📋 Version Notes

Java 8

Same wrapping semantics; internal frames read reportJoin/postComplete/encodeThrowable — verified in jdk8u sources, where wrapInCompletionException does not exist at all (it is a later JDK addition).

Java 12

exceptionallyCompose arrives for fallback chains that themselves return futures.

Java 21

Unchanged; virtual-thread carriers execute AsyncSupply without pinning, trace shapes intact.

🛡️ How to Prevent This Next Time

Route every CompletableFuture chain through project-standard exceptionally/whenComplete wiring, keep a single root-cause utility in the shared library, and review any join() whose enclosing method neither catches nor documents CompletionException.