Chapter 14 of 18
14 — Select & timeouts
CompletableFuture.anyOf to race tasks, plus timeouts that fail loudly.
Kata: a Racer that returns whichever of two tasks finishes first — and fails loudly if neither does in time.
New ideas: CompletableFuture.anyOf, timeouts with get(long, TimeUnit), and turning a “might hang forever” into a tested failure.
This chapter is a kata — write the code yourself. If you’re following along inside the solutions repo, the finished class also ships in
src/main/java/select— don’t open it yet. Write the code from the steps below first and run the tests. The second RED below is why timeouts exist.
The pattern: the caller doesn’t know who wins
Some decisions can’t be made by the caller: “call the primary, but if it’s slow, use the backup” is a race. No amount of inspection tells you which will finish first — so the answer is “whichever completes first, give me that one.”
Step 1 — RED: the test names the contract
Two tasks so far apart that the outcome is deterministic without asserting on time:
@Test
void returnsTheFasterResponse() {
String winner = Racer.race(
sleepy(10, "fast"),
sleepy(1_000, "slow"),
COMFORTABLE_TIMEOUT);
assertEquals("fast", winner);
}
And a second test that pins the failure mode — what happens when neither finishes in time:
@Test
void givesUpWhenNoOneRespondsInTime() {
NoResponderException exception = assertThrows(
NoResponderException.class,
() -> Racer.race(
sleepy(2_000, "first"),
sleepy(2_000, "second"),
Duration.ofMillis(50)));
assertTrue(exception.getMessage().contains("no responder"));
}
Run gradle test --tests "select.RacerTest":
error: cannot find symbol
String winner = Racer.race(
^
symbol: class Racer
RED. And NoResponderException doesn’t exist either — one more cannot find symbol. The
tests just declared two types you haven’t made.
Step 2 — RED: the naive version passes the happy path…
The most obvious implementation calls the first task and calls it a day:
public static String race(Callable<String> first, Callable<String> second, Duration timeout) {
try {
return first.call();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
Run the tests:
RacerTest > returnsTheFasterResponse() PASSED
RacerTest > givesUpWhenNoOneRespondsInTime() FAILED
org.opentest4j.AssertionFailedError: Expected select.NoResponderException to be thrown, but nothing was thrown.
The happy path passed by accident — first is the fast task. It’s the timeout
contract that catches the shortcut. This is the pattern behind every timeout feature you’ve ever
waited on: the “works normally” path never exercises it.
Step 3 — GREEN: race and bound the wait
Run both as futures; anyOf completes as soon as the first one does:
CompletableFuture<Object> winner = CompletableFuture.anyOf(
CompletableFuture.supplyAsync(asSupplier(first)),
CompletableFuture.supplyAsync(asSupplier(second)));
try {
return (String) winner.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
throw new NoResponderException("no responder within " + timeout);
}
get(timeout) throws TimeoutException when neither task makes it in time — and that becomes
your clean, user-visible contract. The timeouts test turns green. (Handle
InterruptedException by restoring the interrupt flag, and ExecutionException by unwrapping
getCause().)
Note the awkward type: anyOf returns CompletableFuture<Object> because its parts could have
different types. That’s the Java flavour of “select over a channel”: first to finish decides.
Java-specific notes
- Checked exceptions at the async seam:
CallablethrowsException, butsupplyAsynctakes aSupplierthat can’t. TheasSupplierhelper catches and rethrows as unchecked — the pragmatic modern answer to checked-exception pain at the seam:
private static Supplier<String> asSupplier(Callable<String> task) {
return () -> {
try {
return task.call();
} catch (Exception e) {
throw new IllegalStateException(e);
}
};
}
- Unwrap
ExecutionException:get()wraps the real failure; work withe.getCause(), not the wrapper. - Interrupted ≠ done: on
InterruptedException, restore the flag (Thread.currentThread().interrupt()) — swallowing it loses the signal that the thread should stop. - Abandoned tasks keep running: the slow loser continues in the background after the winner
returns. For fast-fail and cleanup that’s the job of
cancel(true)and, later, structured concurrency (see roadmap). - On JDK 9+, the same idea reads as
future.orTimeout(duration).join()—orTimeoutdoes the timeout on the future.
Run it
gradle test --tests "select.*"
Key takeaway: timeouts turn “might hang forever” into a tested, named failure. anyOf is how
you race; get(timeout) is how you bound the wait — and the test that caught the shortcut in
Step 2 is the one you’ll want in your codebase too.
Next: 15 — sync — thread safety without “just add a lock”.