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
| Class | Purpose |
|---|---|
HttpClient | Manages connections, connection pools, TLS, redirects, authentication |
HttpRequest | An 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)