🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
java.util.concurrent.ExecutionException: java.lang.RuntimeException: Payment gateway unreachable after 3 attempts
at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:124)
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:193)
at com.devinhyderabad.checkout.CheckoutService.completeOrder(CheckoutService.java:47)
Caused by: java.lang.RuntimeException: Payment gateway unreachable after 3 attempts
at com.devinhyderabad.checkout.GatewayClient.charge(GatewayClient.java:58)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
at java.base/java.lang.Thread.run(Thread.java:1474)⚡ Quick Fix Works 80% of the time
Log and branch on getCause() — the wrapper carries no information of its own; the cause is the actual bug.
try {
receipt = chargeFuture.get(2, TimeUnit.SECONDS);
} catch (ExecutionException e) {
Throwable real = e.getCause();
log.error("charge failed", real); // full original stack
if (real instanceof GatewayDownException gd) {
throw new PaymentDeclinedException(gd.getMessage(), gd);
}
if (real instanceof RuntimeException re) {
throw re; // runtime causes: rethrow as-is
}
throw new IllegalStateException(real); // checked causes need a wrapper
}🧠 Why this Happens
Tap to expand the deep technical explanation
FutureTask records the worker’s outcome in a single field: null for success or the thrown Throwable. When get() observes that field, report() cannot rethrow arbitrary checked types through the method signature, so it constructs new ExecutionException(outcome) — its message being little more than cause.toString(). The original stack trace survives intact beneath the Caused by marker because the wrapper holds a plain reference, not a copy.
The HITEC City Parking Spot Analogy:
A courier delivers a damaged parcel sealed in a branded outer box. The box label (wrapper message) just names the damage report inside; opening the inner package (Caused by) reveals what actually happened and where.
🔁 How to Reproduce Confirm this is your error
Submit a Callable that throws RuntimeException("Payment gateway unreachable after 3 attempts"), call get(), and compare the two-part trace. (Lab capture: OpenJDK Temurin 25.0.2; app frames renamed.)
🛠️ Solutions (5 Ways to Fix)
Unwrap getCause() and handle the real type
👉 Use this when/if call sites need different reactions per failure kind.
Switch on the unwrapped class: retry transient gateway faults, surface validation errors to users, escalate programming bugs. Handling the wrapper generically conflates these, and logging only the wrapper line discards the actionable stack sitting one level down.
catch (ExecutionException e) {
Throwable c = e.getCause();
if (c instanceof TransientNetworkException tn && retriesLeft()) {
scheduleRetry(tn);
} else if (c instanceof ValidationException ve) {
ui.showFieldError(ve);
} else if (c instanceof RuntimeException re) {
throw re;
} else {
throw new IllegalStateException(c); // checked causes need a wrapper
}
}Return result-or-error values instead of throwing inside Callables
👉 Use this when/if failures are expected outcomes worth modeling.
A sealed Result interface (Success | Failure) lets tasks complete normally with rich error payloads. get() then never wraps anything, pattern matching handles cases exhaustively, and the exception hierarchy stops leaking across the async boundary.
sealed interface ChargeResult permits Charged, Declined, GatewayDown {}
FutureTask<ChargeResult> task = new FutureTask<>(gateway::charge);
// caller
ChargeResult r = task.get(2, TimeUnit.SECONDS);
switch (r) {
case Charged c -> ship(c.order());
case Declined d -> askOtherCard(d.reason());
case GatewayDown g -> queueRetry(g.attempt());
}Normalize errors at composition time with exceptionally
👉 Use this when/if many consumers share one CompletableFuture chain.
.exceptionally maps the raw worker failure into a domain exception once, at the source. Every later get()/join sees the meaningful type directly, eliminating scattered unwrap boilerplate and keeping wrapper noise out of logs entirely.
CompletableFuture<Receipt> safe = chargeAsync(order)
.exceptionally(ex -> {
if (findCause(ex) instanceof GatewayDown g) {
throw new PaymentTemporarilyUnavailable(g);
}
throw asRuntime(ex);
});Fail synchronously before crossing the async boundary
👉 Use this when/if most wrapped failures trace back to bad inputs.
Validating arguments in the submitting thread throws directly to the true caller with no wrapper involved, reserving async execution for work that can legitimately fail remotely. Cheap precondition checks shrink both exception volume and confusion.
public Future<Receipt> charge(Order order) {
Objects.requireNonNull(order.cardToken(), "cardToken");
if (order.amount().signum() <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
return executor.submit(() -> gateway.charge(order));
}DEV ONLY: catch ExecutionException and blindly retry forever
👉 Use this only to observe retry amplification in a sandbox — it turns one outage into a storm.
Without inspecting the cause, retries hammer a hard-down endpoint at full rate, multiply load during incidents, and bury the originating stack under layers of retry noise. Cause inspection is what separates resilience from denial.
// ANTI-PATTERN — do not ship
while (true) {
try { return future.get(); }
catch (ExecutionException e) { future = resubmit(); } // never inspects cause
}📋 Version Notes
Wrapping behavior identical; CompletableFuture.join users see CompletionException for the same scenario.
No change — FutureTask.report remains the wrapping frame.
Structured concurrency previews aim to reduce wrapper noise by scoping exceptions to the scope owner; classic Future semantics unchanged.
🛡️ How to Prevent This Next Time
Standardize one unwrapping utility (strip ExecutionException/CompletionException layers to the root) and route all async results through it; prefer modeling expected failures as values over throwing across futures; require cause-aware logging in every catch of a concurrent API.