Asynchronous Requests and Timeouts
Fan out calls with sendAsync, unwrap failures correctly, and put connect and request timeouts on every outbound call.
CompletableFuture<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 withHttpConnectTimeoutException.HttpRequest.newBuilder().timeout(Duration): how long to wait for the response headers. It fails withHttpTimeoutException(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
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.
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: trueUnwrapping 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.
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.
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