Part 9 of 16

HTTP Client API (JEP 321): HTTP/2, Async, and Authentication

Why a New HTTP Client?

HttpURLConnection — Java’s HTTP API since Java 1.1 — has deep design problems:

  • Mutable shared state makes it error-prone in multithreaded code
  • No built-in HTTP/2 support
  • No built-in async; non-blocking requires manual thread management
  • Clunky API: setDoOutput(true), getOutputStream(), connect() in sequence
  • No support for reactive streams

JEP 321 (Java 11) standardised the HTTP Client API that was incubating since Java 9. The new API lives in java.net.http.


The Three Core Classes

ClassPurpose
HttpClientManages connections, connection pools, TLS, redirects, authentication
HttpRequestAn immutable HTTP request (method, URI, headers, body)
HttpResponse<T>A received response with status, headers, and typed body

Creating an HttpClient

import java.net.http.*;
import java.net.URI;
import java.time.Duration;

// Minimal client — sensible defaults
var client = HttpClient.newHttpClient();

// Fully configured client
var client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)          // prefer HTTP/2, fall back to 1.1
    .connectTimeout(Duration.ofSeconds(10))
    .followRedirects(HttpClient.Redirect.NORMAL)  // follow 301/302, not HTTPS→HTTP
    .executor(Executors.newFixedThreadPool(4))    // custom thread pool for async
    .build();

HttpClient is thread-safe and intended to be reused across requests. Create one per application (or per logical service), not one per request.


Building an HttpRequest

HttpRequest is immutable. The builder enforces required fields.

var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users/42"))
    .GET()                                        // GET is the default
    .header("Accept", "application/json")
    .header("Authorization", "Bearer " + token)
    .timeout(Duration.ofSeconds(5))
    .build();

HTTP Methods

// GET (default)
HttpRequest.newBuilder(URI.create(url)).build();

// POST with JSON body
HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users"))
    .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
    .header("Content-Type", "application/json")
    .build();

// PUT
HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users/42"))
    .PUT(HttpRequest.BodyPublishers.ofString(jsonBody))
    .header("Content-Type", "application/json")
    .build();

// DELETE
HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users/42"))
    .DELETE()
    .build();

// Custom method
HttpRequest.newBuilder()
    .uri(URI.create(url))
    .method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
    .build();

Body Publishers

BodyPublishers creates request body sources:

// String body
HttpRequest.BodyPublishers.ofString("{\"name\":\"Alice\"}")

// Byte array
HttpRequest.BodyPublishers.ofByteArray(bytes)

// File
HttpRequest.BodyPublishers.ofFile(Path.of("upload.csv"))

// No body (for GET, DELETE)
HttpRequest.BodyPublishers.noBody()

// InputStream (lazily read)
HttpRequest.BodyPublishers.ofInputStream(() -> new FileInputStream("data.bin"))

Synchronous Requests

var client  = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.github.com/repos/openjdk/jdk"))
    .header("Accept", "application/vnd.github.v3+json")
    .build();

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

System.out.println(response.statusCode());  // 200
System.out.println(response.body());        // JSON string

client.send() blocks the calling thread until the response is complete.


Body Handlers

BodyHandlers controls how the response body is converted:

// As String
HttpResponse.BodyHandlers.ofString()

// As byte array
HttpResponse.BodyHandlers.ofByteArray()

// Save directly to a file
HttpResponse.BodyHandlers.ofFile(Path.of("downloaded.zip"))

// Discard body (e.g., for DELETE, HEAD)
HttpResponse.BodyHandlers.discarding()

// As InputStream (for streaming)
HttpResponse.BodyHandlers.ofInputStream()

// As lines (Stream<String>)
HttpResponse.BodyHandlers.ofLines()

Asynchronous Requests

sendAsync() returns a CompletableFuture<HttpResponse<T>>, never blocking:

var client  = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/products"))
    .build();

CompletableFuture<String> future = client.sendAsync(request,
        HttpResponse.BodyHandlers.ofString())
    .thenApply(response -> {
        if (response.statusCode() != 200) {
            throw new RuntimeException("HTTP " + response.statusCode());
        }
        return response.body();
    });

// Do other work...
String body = future.join();  // block here only when you actually need the result

Parallel requests

var urls = List.of(
    "https://api.example.com/data/1",
    "https://api.example.com/data/2",
    "https://api.example.com/data/3"
);

var client = HttpClient.newHttpClient();

var futures = urls.stream()
    .map(url -> HttpRequest.newBuilder(URI.create(url)).build())
    .map(req -> client.sendAsync(req, HttpResponse.BodyHandlers.ofString()))
    .collect(Collectors.toList());

// Wait for all and collect results
List<String> results = futures.stream()
    .map(CompletableFuture::join)
    .map(HttpResponse::body)
    .collect(Collectors.toList());

All requests run in parallel; total time ≈ the slowest request, not the sum.


Authentication

Basic Authentication

Java 11’s HttpClient supports Authenticator for automatic credential negotiation:

var client = HttpClient.newBuilder()
    .authenticator(new Authenticator() {
        @Override
        protected PasswordAuthentication getPasswordAuthentication() {
            return new PasswordAuthentication("user", "password".toCharArray());
        }
    })
    .build();

For APIs that use a fixed Authorization header (Bearer token), set it directly on the request instead:

var request = HttpRequest.newBuilder()
    .uri(URI.create(url))
    .header("Authorization", "Bearer " + accessToken)
    .build();

Custom authenticator per-request

// Different credentials for different hosts
var client = HttpClient.newBuilder()
    .authenticator(new Authenticator() {
        @Override
        protected PasswordAuthentication getPasswordAuthentication() {
            if (getRequestingHost().contains("internal.example.com")) {
                return new PasswordAuthentication("svc-user", servicePassword.toCharArray());
            }
            return null;
        }
    })
    .build();

HTTPS/TLS Configuration

By default, HttpClient uses the JVM’s default SSLContext, which trusts the standard CA bundle. For custom TLS:

var sslContext = SSLContext.getInstance("TLS");
sslContext.init(keyManagers, trustManagers, new SecureRandom());

var client = HttpClient.newBuilder()
    .sslContext(sslContext)
    .sslParameters(new SSLParameters(
        new String[]{"TLS_AES_256_GCM_SHA384"},   // cipher suites
        new String[]{"TLSv1.3"}                   // protocols
    ))
    .build();

Handling HTTP Errors

The client does not automatically throw on 4xx/5xx responses. You must check the status:

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

switch (response.statusCode()) {
    case 200, 201 -> return parseBody(response.body());
    case 404      -> throw new ResourceNotFoundException(response.uri());
    case 429      -> throw new RateLimitException("Retry-After: " +
                        response.headers().firstValue("Retry-After").orElse("unknown"));
    default       -> throw new HttpException(response.statusCode(), response.body());
}

Retry with Exponential Backoff

public String getWithRetry(HttpClient client, HttpRequest request,
                           int maxAttempts) throws Exception {
    int attempt = 0;
    while (true) {
        try {
            var response = client.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() == 200) {
                return response.body();
            }
            if (response.statusCode() == 429 || response.statusCode() >= 500) {
                if (++attempt >= maxAttempts) throw new HttpException(response.statusCode());
                Thread.sleep((long) Math.pow(2, attempt) * 100);  // 200, 400, 800 ms...
                continue;
            }
            throw new HttpException(response.statusCode());
        } catch (IOException e) {
            if (++attempt >= maxAttempts) throw e;
            Thread.sleep((long) Math.pow(2, attempt) * 100);
        }
    }
}

Streaming Responses

For large responses, stream the body line-by-line rather than loading it all into memory:

var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/events"))
    .build();

try (var response = client.send(request, HttpResponse.BodyHandlers.ofLines())) {
    response.body()
        .filter(line -> !line.isBlank())
        .map(this::parseEvent)
        .forEach(eventProcessor::process);
}

Migrating from HttpURLConnection

// Before — HttpURLConnection (Java 8)
URL url = new URL("https://api.example.com/data");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(10_000);
int status = conn.getResponseCode();
String body;
try (var reader = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
    body = reader.lines().collect(Collectors.joining("\n"));
}

// After — HttpClient (Java 11)
var client   = HttpClient.newHttpClient();
var request  = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/data"))
    .header("Accept", "application/json")
    .timeout(Duration.ofSeconds(10))
    .build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
int status   = response.statusCode();
String body  = response.body();

What’s Next

Next: Tooling: JShell, jlink, and Single-File Programs (JEP 222, 282, 330)