🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
java.util.concurrent.CompletionException: java.util.concurrent.TimeoutException
at java.base/java.util.concurrent.CompletableFuture.wrapInCompletionException(CompletableFuture.java:323)
at java.base/java.util.concurrent.CompletableFuture.reportJoin(CompletableFuture.java:457)
at java.base/java.util.concurrent.CompletableFuture.join(CompletableFuture.java:2139)
at com.devinhyderabad.pricing.PriceAggregator.bestOffer(PriceAggregator.java:38)
Caused by: java.util.concurrent.TimeoutException
at java.base/java.util.concurrent.CompletableFuture$Timeout.run(CompletableFuture.java:2828)
at java.base/java.util.concurrent.DelayScheduler$ScheduledForkJoinTask.compute(DelayScheduler.java:510)
at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:511)
at java.base/java.util.concurrent.DelayScheduler.loop(DelayScheduler.java:325)
at java.base/java.util.concurrent.DelayScheduler.run(DelayScheduler.java:221)
// Same expiry on Java 17 / 21 (DOC-DERIVED — Delayer implementation, verified
// against jdk17u sources; line numbers vary by build). The timer fires on the
// shared single-thread ScheduledThreadPoolExecutor whose daemon is named
// CompletableFutureDelayScheduler:
Caused by: java.util.concurrent.TimeoutException
at java.base/java.util.concurrent.CompletableFuture$Timeout.run(CompletableFuture.java)
at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java)
at java.base/java.lang.Thread.run(Thread.java)⚡ Quick Fix Works 80% of the time
Substitute a default on expiry with completeOnTimeout, and remember the wrapper depends on the accessor — join throws CompletionException, get throws ExecutionException.
String offer = fetchLivePrice(sku)
.orTimeout(400, TimeUnit.MILLISECONDS)
.completeOnTimeout(lastKnownGoodPrice(sku), 400, TimeUnit.MILLISECONDS)
.join(); // now yields the fallback, never the CE🧠 Why this Happens
Tap to expand the deep technical explanation
orTimeout schedules an internal Timeout runnable on a shared DelayScheduler thread. When it fires, it completes the future with a bare new TimeoutException() — the JDK attaches no text, so any wording you see came from libraries or wrappers, not this API. Because the failure travels through normal exceptional completion, join() rethrows it wrapped in unchecked CompletionException while get() would wrap it in checked ExecutionException; the Caused by frame naming CompletableFuture$Timeout.run identifies the timer as sender.
The HITEC City Parking Spot Analogy:
A parking meter chimes at the limit and clamps the car — the chime itself says nothing (bare exception), and whether you hear a siren (join) or a letter by post (get) depends on which complaint channel the city uses.
🔁 How to Reproduce Confirm this is your error
supplyAsync(() -> { sleep(1500); return "late"; }).orTimeout(300, MILLISECONDS).join() produces exactly this CE-plus-Caused-by pair. Swap join() for get() to see ExecutionException instead. (Lab capture: OpenJDK Temurin 25.0.2.)
🛠️ Solutions (5 Ways to Fix)
Degrade with completeOnTimeout instead of failing
👉 Use this when/if a stale-but-valid answer beats an exception for your feature.
completeOnTimeout(value, t, u) races the timer against a default, converting expiry into ordinary successful completion. Downstream stages and callers stay untouched, and the pipeline expresses its SLA as data flow rather than try/catch scaffolding.
CompletableFuture<List<Fare>> fares = searchFares(route)
.orTimeout(600, TimeUnit.MILLISECONDS)
.completeOnTimeout(cachedFares(route), 600, TimeUnit.MILLISECONDS);Unwrap the cause to separate timeouts from business failures
👉 Use this when/if one handler must react differently to expiry versus domain errors.
The wrapper class varies with accessor, but getCause() is stable: a TimeoutException there means deadline, anything else means the stage itself threw. Classifying on the unwrapped type makes retry and alert logic precise instead of guesswork.
try {
return stage.join();
} catch (CompletionException | ExecutionException e) {
if (e.getCause() instanceof TimeoutException te) {
return fallback(); // deadline path
}
throw e.getCause(); // real failure path
}Put deadlines on inner stages too — outer timers do not cancel work
👉 Use this when/if long-running suppliers keep burning workers after orTimeout fires.
orTimeout completes the outer future exceptionally but never interrupts the supplier; the orphaned task occupies its thread to completion. Guard expensive inner calls with their own orTimeout plus explicit cancel, or scope the whole unit with StructuredTaskScope so cancellation propagates.
var inner = supplyAsync(() -> slowRpc(q));
var guarded = inner.orTimeout(300, MILLISECONDS)
.handle((v, ex) -> { if (ex != null) inner.cancel(true); return v; });Prefer checked get(timeout) at service boundaries
👉 Use this when/if the caller should consciously handle deadline failure.
At API edges, get(n, u) forces callers to acknowledge both interruption and timeout through checked exceptions, keeping CompletionException surprises out of stream lambdas deep inside the codebase. Reserve join()/orTimeout for internal composition.
public Quote bestQuote(String sku) throws TimeoutException, InterruptedException {
try {
return pipeline.applyToEither(fallbackPipeline, Function.identity())
.get(900, TimeUnit.MILLISECONDS);
} catch (ExecutionException e) {
throw new PricingException(e.getCause());
}
}DEV ONLY: sprinkle orTimeout(1, HOURS) everywhere as insurance
👉 Use this only as a temporary tripwire while hunting an unbounded hang — never as the design.
Hour-long timers neither protect users nor free workers meaningfully, and they mask whichever stage genuinely lacks a budget. Timeouts should encode per-dependency SLAs measured in milliseconds, applied deliberately.
// ANTI-PATTERN — do not ship
supplyAsync(expensiveJob).orTimeout(1, TimeUnit.HOURS); // feels safe, helps nobody📋 Version Notes
No orTimeout/completeOnTimeout — emulate with a scheduled completer racing the future.
orTimeout, completeOnTimeout, and delayedExecutor introduced with this exact bare-exception behavior.
Internal Delayer machinery refactored toward DelayScheduler; trace frames rename, semantics unchanged.
Timer scheduling coexists cleanly with virtual threads; the shared scheduler stays a single daemon regardless of platform.
🛡️ How to Prevent This Next Time
Give every external call an explicit millisecond budget derived from its SLO, standardize on completeOnTimeout-or-fail-fast policies per feature, and add a test asserting that firing the timer cancels or bounds the underlying work — not just that the future completes.