01
Distinguish submission from completion
Identify what the caller’s response means. A queue-acceptance response can be valid if the system deliberately exposes later task status. It becomes misleading when downstream code treats that response as evidence that the work finished successfully.
Our first fixture submitted a task that threw IllegalStateException and printed a queued message. After the executor finished, a test-only status check confirmed exceptional completion. There was no success result simply because the submission call returned.
02
Keep the futures at the ownership boundary
Retain a result handle for the work whose outcome matters. If several tasks contribute to a batch, decide what constitutes batch success and how each failure is surfaced. Unrelated work should not be accidentally included merely to make a batch wait.
For CompletableFuture.allOf, the combined future tracks completion of its inputs and can complete exceptionally. It does not collect every task’s value, automatically roll back successful tasks, or make their external effects atomic.
03
Surface the cause in a controlled reproduction
Save this diagnostic program as AsyncFailureProbe.java. It intentionally handles the expected failure so assertions can inspect the cause and the successful sibling. Its output labels the batch as failed.
In application code, connect that failure to the actual response or job status. Logging an exception while still returning an ordinary success result is a separate decision and is not fixed by merely adding the catch block shown in a probe.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executors;
public class AsyncFailureProbe {
public static void main(String[] args) {
try (var pool = Executors.newFixedThreadPool(2)) {
CompletableFuture<String> failed = CompletableFuture.supplyAsync(() -> {
throw new IllegalStateException("downstream unavailable");
}, pool);
var successful = CompletableFuture.supplyAsync(() -> "ready", pool);
try {
CompletableFuture.allOf(failed, successful).join();
throw new AssertionError("Failure was hidden");
} catch (CompletionException error) {
if (!(error.getCause() instanceof IllegalStateException)) throw error;
System.out.println("batch failed=" + error.getCause().getMessage());
}
if (!successful.join().equals("ready")) throw new AssertionError();
}
}
}04
Verify both failure and surviving work
Compile and run the probe. It should print batch failed=downstream unavailable. The assertions also check that the cause is IllegalStateException and the successful sibling’s result remains ready.
The fixture uses a locally owned executor and waits for finite local tasks. Do not generalize its behavior to network deadlines or assume allOf cancels outstanding work when one input fails.
javac -d out AsyncFailureProbe.java
java -cp out AsyncFailureProbe05
Choose observation appropriate to the application
Blocking with join can be appropriate in a small command-line test, but an event-driven server may need composed stages and explicit failure handling without blocking a request thread. The key requirement is an owned, visible result path.
Test the caller’s final status under a failing dependency, not only a log line. Preserve the cause, affected operation identity, and expected handling. Separately verify retry, timeout, idempotency, and cancellation behavior where the real workflow requires them.
Know the limits
Reproduced with Eclipse Temurin OpenJDK 25.0.4.1+1 in a disposable Linux container with no external network. An ignored future completed exceptionally after its caller reported queued work. Observing allOf(...).join exposed CompletionException with the intended cause; a successful sibling remained successful. Real service timeouts, cancellation propagation, and external side effects were not tested.
Sources & further reading
A technical publication by CoreTrailKit.
Browse all articles