Learn Java with Tests

A TDD course for Java, in the spirit of Learn Go with Tests.

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: Callable throws Exception, but supplyAsync takes a Supplier that can’t. The asSupplier helper 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 with e.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()orTimeout does 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”.