HttpClient: GET Requests
Call other services with java.net.http.HttpClient: one shared client, encoded query parameters, status checks and redirects done deliberately.
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder(URI.create(url)).build();
HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
int status = res.statusCode();java.net.http.HttpClient (Java 11) is the JDK's HTTP client. It supports HTTP/1.1 and HTTP/2, synchronous and asynchronous calls, and has no dependencies. Three types do the work: HttpClient sends, HttpRequest describes what to send, and a BodyHandler decides what the response body becomes.
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create(url)).header("Accept", "application/json").build(),
HttpResponse.BodyHandlers.ofString());
One client per application
A client owns a connection pool and background threads, and it is immutable and thread-safe. Create one (or one per downstream service with different settings) at startup and share it. Building a new client per request throws away pooled connections, so every call pays for a fresh TCP connection and TLS handshake. Since Java 21 HttpClient implements AutoCloseable; close it on shutdown, or use try-with-resources in short programs like the ones here.
Requests
HttpRequest.newBuilder(uri) defaults to GET. Add headers such as Accept and Authorization with header(), and a per-request timeout(). Build query strings by encoding each value with URLEncoder.encode(value, StandardCharsets.UTF_8); concatenating raw user input into a URL breaks on spaces and lets a value smuggle in extra parameters.
Status codes do not throw
send throws IOException only when there is no usable response: connection refused, reset, or a timeout. A 404 or 503 is a normal return value. Check statusCode() before parsing the body, and turn unexpected codes into an exception or a domain error yourself. Forgetting this is how an HTML error page from a proxy ends up fed to a JSON parser.
Redirects
The default policy is Redirect.NEVER: a 301 or 302 comes back to you as-is. Use followRedirects(HttpClient.Redirect.NORMAL) on the builder to follow them, except from HTTPS to HTTP. response.uri() then tells you where the body actually came from and previousResponse() holds the redirect.
Body handlers
ofString(): the whole body as text, decoded with the charset fromContent-Typeor UTF-8.ofByteArray()for binary data,ofFile(path)to stream a download to disk.ofLines()orofInputStream()to process a large body without holding it all in memory.discarding()when only the status matters.
send blocks the calling thread until the body has been handled. On virtual threads that is cheap, which makes the synchronous API the simplest choice inside request handlers.
Example
When to use it
- Call a partner's REST API from a service using one shared, pre-configured HttpClient.
- Map a downstream 404 to Optional.empty() and a 5xx to an exception in a small API client class.
- Stream a large CSV export from another service and process it without loading it into memory.
More examples
A 404 is a return value, not an exception
Mapping status codes in one method keeps the rest of the service free of HTTP details: 404 becomes an empty <code>Optional</code>, anything unexpected becomes an exception. Without the switch, the 503 body "error" would have been returned as if it were customer data.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
record Customer(String id, String body) {}
Optional<Customer> findCustomer(HttpClient client, String base, String id)
throws IOException, InterruptedException {
HttpResponse<String> r = client.send(
HttpRequest.newBuilder(URI.create(base + "/customers/" + id)).build(),
HttpResponse.BodyHandlers.ofString());
return switch (r.statusCode()) {
case 200 -> Optional.of(new Customer(id, r.body()));
case 404 -> Optional.empty();
default -> throw new IOException("customer service returned " + r.statusCode());
};
}
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/customers/", exchange -> {
String id = exchange.getRequestURI().getPath().substring("/customers/".length());
int status = switch (id) {
case "c1" -> 200;
case "boom" -> 503;
default -> 404;
};
byte[] body = (status == 200 ? "{\"name\":\"Ada\"}" : "error").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
for (String id : List.of("c1", "c2", "boom")) {
try {
IO.println(id + ": " + findCustomer(client, base, id));
} catch (IOException e) {
IO.println(id + ": " + e.getMessage());
}
}
} finally {
server.stop(0);
}
}
// Output:
// c1: Optional[Customer[id=c1, body={"name":"Ada"}]]
// c2: Optional.empty
// boom: customer service returned 503Following redirects deliberately
With the default client the move surfaces as a 301 and an empty body, which code that only reads the body will misreport as "no data". The following client hides the hop but still records it, which is useful for logging that a partner has moved an endpoint.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/v1/report", exchange -> {
exchange.getResponseHeaders().set("Location", "/v2/report");
exchange.sendResponseHeaders(301, -1);
exchange.close();
});
server.createContext("/v2/report", exchange -> {
byte[] body = "report v2".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
HttpRequest request = HttpRequest.newBuilder(
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/report")).build();
try (HttpClient plain = HttpClient.newHttpClient();
HttpClient following = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()) {
HttpResponse<String> r1 = plain.send(request, HttpResponse.BodyHandlers.ofString());
IO.println("default: " + r1.statusCode() + " body='" + r1.body() + "' location="
+ r1.headers().firstValue("location").orElse("-"));
HttpResponse<String> r2 = following.send(request, HttpResponse.BodyHandlers.ofString());
IO.println("following: " + r2.statusCode() + " body='" + r2.body() + "' from " + r2.uri().getPath()
+ " after " + r2.previousResponse().map(HttpResponse::statusCode).orElse(0));
} finally {
server.stop(0);
}
}
// Output:
// default: 301 body='' location=/v2/report
// following: 200 body='report v2' from /v2/report after 301Streaming a large body line by line
<code>ofLines()</code> hands you a lazy <code>Stream</code>, so a large export is filtered as it arrives rather than buffered as one huge string. Always close that stream (try-with-resources) or the connection cannot be reused.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/export.csv", exchange -> {
exchange.getResponseHeaders().set("Content-Type", "text/csv");
exchange.sendResponseHeaders(200, 0); // chunked: size not known up front
try (OutputStream out = exchange.getResponseBody()) {
out.write("id,status\n".getBytes(StandardCharsets.UTF_8));
for (int i = 1; i <= 10_000; i++) {
String status = i % 1000 == 0 ? "failed" : "ok";
out.write((i + "," + status + "\n").getBytes(StandardCharsets.UTF_8));
}
}
});
server.start();
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/export.csv");
try (HttpClient client = HttpClient.newHttpClient()) {
HttpResponse<Stream<String>> response = client.send(
HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofLines());
// Lines are read as the stream is consumed; close it to release the connection.
try (Stream<String> lines = response.body()) {
List<String> failed = lines.skip(1)
.filter(line -> line.endsWith(",failed"))
.map(line -> line.substring(0, line.indexOf(',')))
.toList();
IO.println(response.statusCode() + " failed ids: " + failed);
}
} finally {
server.stop(0);
}
}
// Output:
// 200 failed ids: [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000]
Discussion