The ticket is a GET against an internal JSON API. The PR adds Apache HttpClient — or OkHttp — plus a factory, a closeable, and a version bump the next time a CVE lands.
You did not need a third-party HTTP stack for that call. Java 11 standardized java.net.http.HttpClient (JEP 321): HTTP/1.1 and HTTP/2, synchronous or asynchronous, with a builder, body handlers, and timeouts that live in the JDK.
Build one client, reuse it, hand each call a request and a body handler. That is the whole surface for ordinary GET and POST.
The GET you used to import a library for
Apache HttpClient 4/5 for a health check is a closeable, an execute, and an entity drain:
try (CloseableHttpClient http = HttpClients.createDefault()) {
HttpGet get = new HttpGet("https://api.example.com/health");
try (CloseableHttpResponse response = http.execute(get)) {
String body = EntityUtils.toString(response.getEntity());
}
}
OkHttp is shorter and still a dependency:
OkHttpClient http = new OkHttpClient();
Request request = new Request.Builder()
.url("https://api.example.com/health")
.build();
try (Response response = http.newCall(request).execute()) {
String body = response.body().string();
}
The same GET with the JDK client is four types and no extra JAR:
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.example.com/health"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
Keep that HttpClient as a field or a Spring bean. It is immutable and thread-safe; it owns a connection pool and a selector thread. Creating a new client per request is the expensive part, not send.
When it shipped
| Release | Status | Spec |
|---|---|---|
| Java 9 | Incubator (jdk.incubator.http) | JEP 110 |
| Java 10 | Incubator, updated | — |
| Java 11 | Standard (java.net.http) | JEP 321 |
Use Java 11+. No preview flag. If you still have jdk.incubator.http imports, they are Java 9/10 samples — the package and module name changed when the API left the incubator.
Note: On the class path the module is already there. A named module needs requires java.net.http;.
Mental model
Four types, one job each:
| Type | Job |
|---|---|
HttpClient | Reusable sender: version preference, connect timeout, redirect policy, executor |
HttpRequest | One call: URI, method, headers, body publisher, per-request timeout |
HttpResponse<T> | What came back: status, headers, negotiated version, body of type T |
BodyHandler<T> / BodyPublisher | How to read the body / how to write it |
The client does not parse JSON and does not map to DTOs. BodyHandlers.ofString() gives you a String; Jackson, Gson, or your record mapper sit after that. Reach for Apache or OkHttp when you need interceptors, a mature connection-pool dashboard, or a library the rest of the process already standardized on — not for “we had to GET a URL.”
Both HttpClient and HttpRequest are immutable once built. Change a timeout or a header by building a new request (or a second client), not by mutating the first.
Configure the client you will reuse
HttpClient.newHttpClient() is newBuilder().build() with defaults. The interesting knobs live on the builder:
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.version(HttpClient.Version.HTTP_2)
.build();
You almost never set version explicitly — HTTP/2 is already the default preferred version. If the origin only speaks HTTP/1.1, the client uses that. HTTP/3 is a later opt-in on Java 26; see HTTP/3 for HttpClient when you are on that JDK. This post stays on the Java 11 client.
Stash one client (or a small handful: different proxy, SSL context, or authenticator). Do not wrap newBuilder().build() inside the method that fires the request.
GET, then POST JSON
A GET with a request timeout and an Accept header:
HttpRequest get = HttpRequest.newBuilder(URI.create("https://api.example.com/orders/42"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> response = client.send(get, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("GET failed: " + response.statusCode());
}
String json = response.body();
A POST is the same builder with a BodyPublisher instead of GET():
String payload = "{\"sku\":\"ABC-1\",\"qty\":2}";
HttpRequest post = HttpRequest.newBuilder(URI.create("https://api.example.com/orders"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> created = client.send(post, HttpResponse.BodyHandlers.ofString());
System.out.println(created.statusCode()); // 201, 200, or 4xx/5xx — still a completed send
send returns for 4xx and 5xx the same way it returns for 200. There is no “throw on non-2xx” switch. Check statusCode() yourself.
Note: BodyPublishers.ofString encodes UTF-8 by default. Set Content-Type (and a charset if you are not sending JSON UTF-8) on the request — the publisher will not invent that header for you.
Body handlers and publishers
The handler decides the type parameter on HttpResponse<T>. Pick the one that matches how you will use the bytes:
| Handler | T | Use when |
|---|---|---|
BodyHandlers.ofString() | String | JSON or text you will parse in memory |
BodyHandlers.ofFile(path) | Path | Download to disk without a giant String |
BodyHandlers.discarding() | Void | You only need status / headers |
BodyHandlers.ofByteArray() | byte[] | Small binary payloads |
HttpResponse<Path> saved = client.send(
HttpRequest.newBuilder(URI.create("https://api.example.com/report.csv")).GET().build(),
HttpResponse.BodyHandlers.ofFile(Path.of("/tmp/report.csv")));
HttpResponse<Void> ping = client.send(
HttpRequest.newBuilder(URI.create("https://api.example.com/health")).GET().build(),
HttpResponse.BodyHandlers.discarding());
System.out.println(ping.statusCode());
ofString() loads the whole body into memory. A multi-hundred-megabyte export belongs in ofFile (or ofInputStream if you will stream it yourself).
On the way out, BodyPublishers.ofString(payload) is the usual JSON POST. ofFile(path) uploads a file; noBody() is what GET() and DELETE() already use.
Timeouts and redirects
Two clocks, two places:
| Clock | Set on | Measures |
|---|---|---|
| Connect timeout | HttpClient.Builder.connectTimeout | Opening the TCP/TLS connection |
| Request timeout | HttpRequest.Builder.timeout | From send until the response body is fully received |
A client without connectTimeout can hang on a black hole. A request without timeout can hang after the socket is up — slow origin, large body, stalled stream. Set both.
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(15))
.GET()
.build();
Redirects are the other default people trip over. The client’s default policy is Redirect.NEVER. A 301 / 302 comes back as that status plus a Location header; send does not follow it.
| Policy | Behaviour |
|---|---|
NEVER | Default. You see the 3xx yourself |
NORMAL | Follow, except HTTPS → HTTP |
ALWAYS | Follow including HTTPS → HTTP |
Set Redirect.NORMAL if you want 301/302 followed the way a browser would for same-scheme (and HTTP→HTTPS) hops. ALWAYS will downgrade HTTPS to HTTP — treat that as a footgun, not a convenience.
When a policy does follow, response.uri() is the final URI after hops. Log it when you debug “why did this call hit a different host?”
send vs sendAsync
send blocks the calling thread until the response is complete. It throws IOException (connect reset, timeout, TLS) and InterruptedException (the thread was interrupted while waiting). If you catch the interrupt, restore the flag.
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
sendAsync returns immediately with a CompletableFuture<HttpResponse<T>>. The same I/O failures complete the future exceptionally instead of throwing from your call site. Chain thenApply / thenAccept, or join() on a thread that is allowed to block — this post does not teach CompletableFuture; it only notes that async HTTP in the JDK is that type.
CompletableFuture<HttpResponse<String>> future =
client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
future.thenAccept(response -> System.out.println(response.statusCode()));
Use send on a request thread that is supposed to wait (a CLI, a test, a worker that has nothing else to do). Use sendAsync when you already orchestrate with futures or cannot occupy a servlet/virtual-thread-unfriendly pool. The client’s executor (override with .executor(...) on the builder) runs the async work; the default is a cached pool the JDK creates for that client.
Cheat sheet
Java 11 / JEP 321: java.net.http (incubator in 9/10 as jdk.incubator.http)
HttpClient = reuse one; immutable; newBuilder() or newHttpClient()
HttpRequest = one call; GET() / POST(publisher); header(); timeout()
HttpResponse<T> = statusCode(), headers(), body(), uri(), version()
BodyHandlers.ofString() | ofFile(path) | discarding() | ofByteArray()
BodyPublishers.ofString(json) | ofFile(path) | noBody()
connectTimeout → client (TCP/TLS)
timeout → request (whole exchange)
followRedirects → default NEVER; NORMAL to follow 301/302 (no HTTPS→HTTP)
version → prefers HTTP/2 (HTTP/1.1 if that is all the origin has)
send → blocks; throws IOException, InterruptedException
sendAsync → CompletableFuture<HttpResponse<T>>; failures on the future
Do:
- Build one
HttpClientand reuse it for the life of the process (or the bean). - Set
connectTimeouton the client andtimeouton the request. - Use
Redirect.NORMALwhen you actually want 301/302 followed. - Check
statusCode()— 4xx/5xx still return fromsend. - Use
ofFile/discardingwhen you should not materialize a hugeString.
Don’t:
- Add Apache HttpClient or OkHttp for a GET/POST the JDK client already covers.
- Create a new
HttpClientper request. - Assume redirects are followed — default is
NEVER. - Assume HTTP/2 is guaranteed — it is a preference;
response.version()is what you got. - Expect a JSON mapper, interceptors, or “throw on 404” from this API.
Wrap-up
JEP 321 put a modern HTTP client in the JDK: one immutable HttpClient, per-call HttpRequests, BodyHandlers / BodyPublishers, blocking send and future-based sendAsync. HTTP/2 is the preferred version; HTTP/1.1 is still there when the origin needs it.
Start with newBuilder(), set both timeouts, pick Redirect.NORMAL if you want hops followed, and keep the client. Parse JSON after ofString(), not inside the client. Leave Apache and OkHttp for the shops that already standardized on them or that need features this API does not pretend to have.
On Java 26 the same client can opt into HTTP/3; that story is HTTP/3 for HttpClient. Until then, Java 11’s client is the one you wanted for ordinary calls.