🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
java.util.concurrent.CancellationException
at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:123)
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:193)
at com.devinhyderabad.search.SearchSession.collect(SearchSession.java:74)⚡ Quick Fix Works 80% of the time
Treat CancellationException as planned flow control — cancellation is an outcome, not a fault; genuine failures arrive separately as ExecutionException or TimeoutException.
try {
Page hits = searchFuture.get(500, TimeUnit.MILLISECONDS);
} catch (CancellationException e) {
// FutureTask throws this bare, cause-less type ONLY after cancel():
// no isCancelled() re-check needed — the state is implied by the throw.
return Page.abandoned(); // quiet, expected
} catch (TimeoutException | ExecutionException e) {
alert.searchBackendProblem(e); // genuine failure: loud
return Page.error();
}🧠 Why this Happens
Tap to expand the deep technical explanation
FutureTask tracks lifecycle in a state integer. cancel() flips the state to CANCELLED (or INTERRUPTING→INTERRUPTED when mayInterruptIfRunning is true) and completes the task with a bare new CancellationException() — deliberately message-less because cancellation carries no failure information. report(), seeing that state on get()/join(), throws it directly rather than wrapping in ExecutionException, signalling waiters that there is no result and no cause, only an aborted contract.
The HITEC City Parking Spot Analogy:
You queue for a table, but your party leaves the restaurant. The host does not seat you or explain a kitchen disaster — the pager simply goes silent with no table ever coming. Nothing broke; the appointment ended.
🔁 How to Reproduce Confirm this is your error
Create a FutureTask, cancel(true), then call get(): bare CancellationException at FutureTask.report, no cause attached. (Lab capture: OpenJDK Temurin 25.0.2.)
🛠️ Solutions (5 Ways to Fix)
Route cancellation to quiet metrics, keep failures loud
👉 Use this when/if user-driven aborts or timeouts routinely cancel in-flight futures.
A cancelled FutureTask throws a bare, cause-less CancellationException by definition, so no isCancelled() re-check is needed — the type itself is the signal. Send it to abandonment metrics while ExecutionException and TimeoutException keep firing real alerts. The one case where extra context exists is a CompletableFuture completed exceptionally with your own constructed CancellationException (next solution).
try {
hits = searchFuture.get(500, TimeUnit.MILLISECONDS);
} catch (CancellationException e) {
metrics.increment("search.abandoned"); // routine outcome
return Page.abandoned();
} catch (TimeoutException | ExecutionException e) {
alert.backendProblem(e); // real failure stays visible
return Page.error();
}Attach a reason by completing exceptionally yourself
👉 Use this when/if operators need to know why a stage was aborted.
CompletableFuture.cancel() always uses the bare JDK exception, but completing the future with your own CancellationException subclass preserves context for whoever consumes it. The state machine treats it identically; your logs gain the missing story.
CompletableFuture<Report> run = runReport(input);
supersedeHook.register(input.id(), () ->
run.completeExceptionally(
new ReportCancelled("superseded by request " + newerId)));Handle both audiences of cancel(true)
👉 Use this when/if shutdown logic cancels running work while others await results.
cancel(true) delivers two different signals: waiters receive CancellationException from get(), while the running thread receives an interrupt surfacing as InterruptedException at its blocking point. Robust teardown handles both paths explicitly so neither workers nor collectors hang.
// waiter side
catch (CancellationException e) { cleanupWaiter(); }
// runner side
try {
rows.forEach(this::index);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new TaskAborted(); // matches what waiters just saw
}Prefer deadline APIs over external cancel for timeouts
👉 Use this when/if cancellation exists only to enforce a time limit.
get(n, u) or orTimeout express intent precisely: expiry produces TimeoutException semantics without anyone mutating shared future state mid-flight. External cancel remains appropriate for user-initiated aborts, where CancellationException genuinely is the right outcome.
// deadline as API, not as cancel()
Result r = future.get(750, TimeUnit.MILLISECONDS); // TE on expiry
// vs external cancel producing CE for every waiter
// future.cancel(true);DEV ONLY: ignore CancellationException and keep consuming results
👉 Use this only to demonstrate orphaned-consumer bugs in training material.
Continuing to poll a cancelled future’s downstream state reads data whose producer stopped mid-write — torn batches, stale indexes, phantom progress bars. The bare exception is the cheapest possible signal that cooperation ended; suppressing it desynchronizes the whole pipeline.
// ANTI-PATTERN — do not ship
catch (CancellationException e) { }
updateUi(resultsSoFar()); // half-written data presented as final📋 Version Notes
Identical bare throw from FutureTask.report; CompletableFuture.cancel completes dependents exceptionally with CancellationException.
No semantic change.
Structured concurrency previews standardize cancellation propagation across related tasks — classic Future behavior unchanged.
🛡️ How to Prevent This Next Time
Treat cancellation as a modeled outcome: dedicated handler branches, reason-carrying subclasses where context helps, and integration tests that cancel in-flight work under load to prove both waiters and workers reach clean terminal states.