Asynchronous Requests and Timeouts

Fan out calls with sendAsync, unwrap failures correctly, and put connect and request timeouts on every outbound call.

SyntaxCompletableFuture<HttpResponse<String>> f = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()); HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)); HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(3));

A service that calls other services inherits their slowness. Two tools keep that under control: running independent calls at the same time, and refusing to wait forever for any of them.

sendAsync

client.sendAsync(request, handler) returns immediately with a CompletableFuture<HttpResponse<T>>. Start every independent call first, then wait for all of them; the total time becomes roughly that of the slowest call instead of the sum. Chain work with thenApply and thenCompose, and use CompletableFuture.allOf to wait for a group.

Failures arrive wrapped. join() throws CompletionException and get() throws ExecutionException; the real cause, such as an HttpTimeoutException or ConnectException, is in getCause(). Inside exceptionally or handle the throwable you receive may also be a CompletionException, so unwrap it before checking its type. As with send, a 404 or 500 is a successful future; only network-level problems fail it.

With virtual threads, the blocking send inside one virtual thread per call achieves the same concurrency with plain sequential code and ordinary try/catch. Many teams now prefer that and keep sendAsync for code that is already built on futures.

Timeouts

By default HttpClient has no timeouts at all: a downstream service that accepts the connection and never answers will hold your request, its thread and the caller's connection indefinitely. Set two:

  • HttpClient.newBuilder().connectTimeout(Duration): how long to wait for a TCP connection. It fails with HttpConnectTimeoutException.
  • HttpRequest.newBuilder().timeout(Duration): how long to wait for the response headers. It fails with HttpTimeoutException (the parent of the connect variant). It does not cover reading the body, so a server that sends headers and then trickles the body can still hold you.

A refused connection (nothing listening on the port) is different again: it fails at once with ConnectException, without waiting for any timeout. Logging these three separately tells you whether a dependency is down, unreachable or merely slow.

Choose timeouts from the caller's budget, not from habit. If your endpoint must answer within two seconds, a downstream call cannot be allowed thirty. For a whole fan-out, or a hard deadline that includes the body, call orTimeout(2, TimeUnit.SECONDS) (Java 9) on the sendAsync or combined future. It fails the future with a TimeoutException but does not abort the underlying exchanges.

Degrading gracefully

Decide per dependency what happens when it fails: return a cached value, omit a section of the response, or fail the whole request with a 503 or 504. Recommendations failing should not stop a product page from loading; a payment provider failing should stop checkout.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Load a customer's profile, orders and recommendations in parallel to build one dashboard response.
  • Put a 2 second budget on a pricing service call so checkout answers 504 instead of hanging.
  • Fall back to an empty recommendations list when that optional service times out.

More examples

A request timeout on a slow dependency

Without the <code>timeout</code> the call would have blocked for the full three seconds, or forever against a server that never answers. The caller can now return a 504 or a fallback well within its own deadline; <code>shutdownNow()</code> (Java 21) closes the client without waiting for the abandoned exchange.

Example · java
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
    server.createContext("/slow", exchange -> {
        try {
            Thread.sleep(3000);   // a dependency having a bad day
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        exchange.sendResponseHeaders(204, -1);
        exchange.close();
    });
    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.start();
    URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/slow");

    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofMillis(300)).build();
    long start = System.nanoTime();
    try {
        client.send(request, HttpResponse.BodyHandlers.discarding());
        IO.println("answered");
    } catch (HttpTimeoutException e) {
        long millis = Duration.ofNanos(System.nanoTime() - start).toMillis();
        IO.println("gave up: " + e.getMessage());
        IO.println("waited less than a second: " + (millis < 1000));
    }
    client.shutdownNow();
    server.stop(0);
}

// Output:
// gave up: request timed out
// waited less than a second: true

Unwrapping async failures and falling back

Checking <code>t instanceof HttpTimeoutException</code> directly inside <code>exceptionally</code> can miss, because a dependent stage may hand you the <code>CompletionException</code> wrapper; unwrapping first makes the check reliable. The fallback keeps an optional dependency from turning into a failed page.

Example · java
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;

Throwable unwrap(Throwable t) {
    return (t instanceof CompletionException || t instanceof ExecutionException) && t.getCause() != null
            ? t.getCause() : t;
}

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
    server.createContext("/recommendations", exchange -> {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        exchange.sendResponseHeaders(204, -1);
        exchange.close();
    });
    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.start();
    URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/recommendations");

    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofMillis(200)).build();

    CompletableFuture<HttpResponse<String>> call = client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
    try {
        call.join();
    } catch (CompletionException e) {
        IO.println("join threw " + e.getClass().getSimpleName()
                + " caused by " + e.getCause().getClass().getSimpleName());
    }

    String recommendations = call
            .thenApply(HttpResponse::body)
            .exceptionally(t -> {
                Throwable cause = unwrap(t);
                IO.println("fallback after " + cause.getClass().getSimpleName()
                        + " (timeout: " + (cause instanceof HttpTimeoutException) + ")");
                return "[]";   // the page still renders, just without recommendations
            })
            .join();
    IO.println("recommendations = " + recommendations);
    client.shutdownNow();
    server.stop(0);
}

// Output:
// join threw CompletionException caused by HttpTimeoutException
// fallback after HttpTimeoutException (timeout: true)
// recommendations = []

Refused, not timed out

The switch checks <code>HttpConnectTimeoutException</code> before its parent <code>HttpTimeoutException</code>, the same ordering rule as catch clauses. A refused connection fails immediately, which is why a crashed dependency shows up as instant errors while a hung one shows up as slow ones.

Example · java
import java.net.http.HttpClient;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;

String classify(Exception e) {
    return switch (e) {
        case HttpConnectTimeoutException t -> "connect timeout: host unreachable or overloaded";
        case HttpTimeoutException t -> "request timeout: connected, but no answer in time";
        case ConnectException c -> "refused: nothing is listening on that port";
        default -> "other: " + e.getClass().getSimpleName();
    };
}

void main() throws Exception {
    int closedPort;
    try (ServerSocket socket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) {
        closedPort = socket.getLocalPort();   // free a port so nothing listens on it
    }

    try (HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build()) {
        HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + closedPort + "/health"))
                .timeout(Duration.ofSeconds(2))
                .build();
        long start = System.nanoTime();
        try {
            client.send(request, HttpResponse.BodyHandlers.discarding());
        } catch (IOException e) {
            long millis = Duration.ofNanos(System.nanoTime() - start).toMillis();
            IO.println(classify(e));
            IO.println("failed before the 2 s timeout: " + (millis < 2000));
        }
    }
}

// Output:
// refused: nothing is listening on that port
// failed before the 2 s timeout: true

Discussion

  • Be the first to comment on this lesson.