The Java HTTP Client

Since Java 11 the platform includes a modern HTTP client with synchronous and asynchronous requests and HTTP/2 support.

Why it was added

The old HttpURLConnection was awkward, synchronous only and limited to HTTP/1.1, which is why almost every project added a third party client. Java 11 made a proper one part of the platform.

Creating a client

HttpClient client = HttpClient.newBuilder()
        .version(HttpClient.Version.HTTP_2)          // falls back to 1.1 automatically
        .connectTimeout(Duration.ofSeconds(5))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();
An HttpClient is immutable, thread safe and holds a connection pool. Create one and reuse it. Building a new client per request throws away pooling and is a common performance mistake.

A GET request

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://example.com/api/notes"))
        .header("Accept", "application/json")
        .timeout(Duration.ofSeconds(10))
        .GET()
        .build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

System.out.println(response.statusCode());
System.out.println(response.headers().firstValue("content-type").orElse("unknown"));
System.out.println(response.body());

POST with a body

String json = """
        {"title": "Java HTTP client", "published": true}""";

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://example.com/api/notes"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(json))
        .build();
Body publisherSends
ofString(text)Text, UTF-8 by default
ofByteArray(bytes)Raw bytes
ofFile(path)A file, streamed
ofInputStream(supplier)A stream
noBody()Nothing

Handling the response

HttpResponse<String> text = client.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<byte[]> bytes = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
HttpResponse<Path> file = client.send(request,
        HttpResponse.BodyHandlers.ofFile(Path.of("download.zip")));
HttpResponse<Stream<String>> lines = client.send(request,
        HttpResponse.BodyHandlers.ofLines());
HttpResponse<Void> discarded = client.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
    process(response.body());
} else {
    throw new IOException("Request failed with " + response.statusCode());
}

The client does not throw on a 404 or a 500. Those are valid HTTP responses, so the status code must be checked explicitly.

Asynchronous requests

CompletableFuture<String> future = client
        .sendAsync(request, HttpResponse.BodyHandlers.ofString())
        .thenApply(HttpResponse::body)
        .exceptionally(error -> {
            logger.warning("Request failed: " + error.getMessage());
            return "";
        });

String body = future.join();
List<CompletableFuture<String>> requests = urls.stream()
        .map(url -> HttpRequest.newBuilder().uri(URI.create(url)).build())
        .map(req -> client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
                .thenApply(HttpResponse::body))
        .toList();

CompletableFuture.allOf(requests.toArray(new CompletableFuture[0])).join();
List<String> bodies = requests.stream().map(CompletableFuture::join).toList();

The asynchronous form returns a CompletableFuture, so many requests run concurrently on the internal executor without a thread each.

Timeouts, at two levels

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))     // establishing the connection
        .build();

HttpRequest request = HttpRequest.newBuilder()
        .uri(uri)
        .timeout(Duration.ofSeconds(30))            // the whole exchange
        .build();

Exceeding the request timeout completes the future exceptionally with an HttpTimeoutException. Set both; a connect timeout alone does not protect against a server that accepts the connection and then stalls.

Authentication and headers

HttpRequest request = HttpRequest.newBuilder()
        .uri(uri)
        .header("Authorization", "Bearer " + token)
        .header("User-Agent", "notes-client/1.0")
        .headers("X-Request-Id", requestId, "Accept", "application/json")
        .build();
// Never log a header that may carry a credential
logger.fine("Calling " + request.uri());

Form data

Map<String, String> form = Map.of("title", "Java HTTP", "status", "draft");

String encoded = form.entrySet().stream()
        .map(entry -> URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
                + "=" + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
        .collect(Collectors.joining("&"));

HttpRequest request = HttpRequest.newBuilder()
        .uri(uri)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .POST(HttpRequest.BodyPublishers.ofString(encoded))
        .build();

Retrying sensibly

public HttpResponse<String> sendWithRetry(HttpRequest request, int attempts)
        throws IOException, InterruptedException {

    IOException last = null;

    for (int attempt = 1; attempt <= attempts; attempt++) {
        try {
            HttpResponse<String> response =
                    client.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() < 500) {
                return response;                    // do not retry a client error
            }
        } catch (HttpTimeoutException | ConnectException e) {
            last = e;
        }
        Thread.sleep(200L * attempt);               // simple back off
    }
    throw last != null ? last : new IOException("All attempts failed");
}

Retry only what is safe to repeat. A GET is naturally idempotent; a POST that creates a record is not, unless the server supports an idempotency key.

A complete example

public class NoteApiClient {

    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

    private final URI baseUri;

    public NoteApiClient(URI baseUri) {
        this.baseUri = baseUri;
    }

    public String fetchNote(long id) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(baseUri.resolve("/api/notes/" + id))
                .header("Accept", "application/json")
                .timeout(Duration.ofSeconds(15))
                .GET()
                .build();

        HttpResponse<String> response =
                CLIENT.send(request, HttpResponse.BodyHandlers.ofString());

        if (response.statusCode() == 404) {
            throw new NoSuchElementException("No note with id " + id);
        }
        if (response.statusCode() != 200) {
            throw new IOException("Unexpected status " + response.statusCode());
        }
        return response.body();
    }
}

What it does not include

There is no JSON parsing in the platform. The client hands you a String or bytes, and mapping to objects needs a library. Keep that mention brief: the client itself is the standard part.

Common mistakes

  • Creating a new HttpClient for every request.
  • Assuming a non 2xx status throws.
  • Setting a connect timeout but no request timeout.
  • Retrying a non idempotent request.
  • Concatenating unencoded values into a query string.
  • Logging authorisation headers.

Best practices

  • Create one client and share it.
  • Set both timeouts on every call.
  • Check the status code explicitly and map it to meaningful exceptions.
  • Use the asynchronous API when several independent calls can overlap.
  • Encode query and form values.
  • Stream large downloads to a file rather than into a String.

Practice

  1. Fetch a public endpoint and print the status, content type and first line of the body.
  2. Send three requests concurrently and collect the results.
  3. Explain why a 404 does not throw, and write the handling it needs.
  4. Add a retry that backs off and never retries a client error.
  5. Download a file directly to disk without holding it in memory.

Conclusion

The standard HTTP client covers most needs without a dependency. Share one instance, set both timeouts, check status codes yourself, and use the asynchronous form when calls can overlap.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →
Java

Introduction to Java

Java is a statically typed, object oriented language that compiles to bytecode and runs on a virtual machine, which is what makes it portable.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.