🔴 The Error You're Seeing

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

ERROR LOGjava.util.concurrent.TimeoutException at java.base/java.util.concurrent.CompletableFuture.timedGet(CompletableFuture.java:1981) at java.base/java.util.concurrent.CompletableFuture.get(CompletableFuture.java:2116) at com.devinhyderabad.checkout.CheckoutService.awaitInventory(CheckoutService.java:63)

⚡ Quick Fix Works 80% of the time

Always bound get() with a timeout, then act on expiry: cancel the future and serve the degraded response — never wait forever on remote work.

try { Inventory inv = future.get(800, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); // stop the wasted work return Inventory.fallbackCache(); // degraded but alive }

🧠 Why this Happens

Tap to expand the deep technical explanation

timedGet parks the caller on the completion signal with a relative nanoTime deadline. When the deadline lapses first, the JDK throws a bare TimeoutException — deliberately message-less because the useful facts (which future, how long) live in your call site, not the JVM. Crucially the future itself is untouched: the underlying task continues consuming a worker thread unless you explicitly cancel it, so repeated timeouts silently pile up zombie work.

The HITEC City Parking Spot Analogy:

Ordering at a counter with a buzzer: when the buzzer rings you walk away, but the kitchen keeps cooking your order anyway until someone tells the kitchen to stop.

🔁 How to Reproduce Confirm this is your error

Create a CompletableFuture that never completes, call cf.get(120, TimeUnit.MILLISECONDS) — bare TimeoutException from timedGet, as captured. (Lab capture: OpenJDK Temurin 25.0.2.)

🛠️ Solutions (5 Ways to Fix)

Solution 1✓ Most common cause

Bound every get and decide the expiry policy explicitly

👉 Use this when/if call sites currently use the infinite get().

A deadline turns an indefinite hang into a handled event. On expiry you hold two honest options — cancel(true) to reclaim the worker, or let-finish-and-record when cancellation is unsafe — and both beat an unbounded park that ties the request thread to the fate of a wedged downstream.

try { Result r = future.get(800, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { boolean interruptedWorker = future.cancel(true); metrics.increment("inventory.timeout"); return Result.degraded(); }
Solution 2

Move deadlines into the pipeline with orTimeout

👉 Use this when/if composition spans multiple async stages.

CompletableFuture.orTimeout applies the deadline where the data flows, so intermediate stages inherit it and the calling thread never parks at all. Pair with completeOnTimeout to substitute a default value instead of failing the chain.

CompletableFuture<Quote> quote = fetchQuote(id) .orTimeout(500, TimeUnit.MILLISECONDS) .completeOnTimeout(Quote.cached(), 700, TimeUnit.MILLISECONDS);
Solution 3

Capture a thread dump during the hang to kill the root cause

👉 Use this when/if timeouts repeat on the same dependency and degrading is expensive.

Repeated timeouts usually mean a stuck peer, not bad luck. Firing jcmd <pid> Thread.print while requests are timing out shows exactly which frame the workers occupy — socket reads without SO_TIMEOUT, lock waits, or a dead database pool — turning symptoms into a named culprit.

# while timeouts are happening jcmd $PID Thread.print > /tmp/hang-dump.txt # look for your task frames parked in socketRead or parkAndCheckInterrupt
Solution 4

Wrap the dependency in a circuit breaker

👉 Use this when/if the remote fails slowly and often.

After N consecutive timeouts the breaker opens and subsequent calls fail immediately with a clear state, protecting the thread pool from stacking timeouts. Resilience4j integrates timers, open-state fallbacks, and half-open probes in a few lines.

CircuitBreaker cb = CircuitBreaker.of("inventory", CircuitBreakerConfig.custom() .slowCallDurationThreshold(Duration.ofMillis(600)) .slowCallRateThreshold(50f) .build()); Supplier<Inventory> guarded = () -> cb.executeSupplier(this::fetch);
Solution 5

DEV ONLY: raise the timeout until tests pass

👉 Use this only to confirm a hang is time-dependent — shipping it converts deadlock into flaky slowness.

If ten seconds fails and sixty passes, the dependency was never slow — it was blocked. Growing the budget hides wedge causes like missing socket timeouts or lock cycles while multiplying user-facing latency.

// ANTI-PATTERN — do not ship future.get(120_000, TimeUnit.MILLISECONDS); // was 800ms last sprint

📋 Version Notes

Java 8

Same bare exception from timedGet; CompletableFuture.orTimeout does not exist yet.

Java 9

orTimeout and completeOnTimeout arrive, letting pipelines carry their own deadlines.

Java 21

Behavior unchanged; virtual-thread callers park without pinning carriers while waiting.

🛡️ How to Prevent This Next Time

Ban the argument-less get() in review, set timeouts from SLO budgets (p99 downstream plus margin, not folklore), track timeout rates per dependency as first-class metrics, and rehearse degradation paths so expiry handling is exercised code, not dead branches.