Calling APIs with HttpClient
Modern applications talk to other applications all day: a shop checks a pincode service before accepting an address, a college portal sends results to an SMS gateway, a travel site asks rail and bus partners for fares. These conversations happen over HTTP, and the data is usually JSON. A program that calls an HTTP service is called a client.
Since Java 11, the JDK has a modern, built-in client in the package java.net.http. In this lesson you will send GET and POST requests, read status codes and headers, convert JSON with Jackson (from JSON with Jackson), set timeouts, retry failed calls safely, and run several calls at once. Every example here was run for real; the later ones use a tiny local server so you get the same output every time.
Your first request
Three classes do all the work: HttpClient sends requests and should be created once and reused; HttpRequest describes one request (URL, method, headers, body, timeout); HttpResponse holds the status code, headers and body.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class FirstGet {
public static void main(String[] args) throws IOException, InterruptedException {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://jsonplaceholder.typicode.com/todos/1"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("Status: " + response.statusCode());
System.out.println("Content-Type: " + response.headers().firstValue("content-type").orElse("?"));
System.out.println("HTTP version: " + response.version());
System.out.println(response.body());
}
}Status: 200
Content-Type: application/json; charset=utf-8
HTTP version: HTTP_2
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}JSONPlaceholder is a free fake API for practice. Some details:
connectTimeoutlimits how long to wait for the connection; the request'stimeoutlimits the wait for the response. Always set both. Without them, a stuck server can block your thread forever.BodyHandlers.ofString()reads the body as text. Others includeofFile(path)to download straight to disk andofLines()for a stream of lines.- The client tries HTTP/2 first and falls back to HTTP/1.1 if the server does not support it.
sendthrowsIOExceptionfor network problems (no internet, DNS failure, timeout) andInterruptedExceptionif the thread is interrupted.
When the network is slow, you will see exceptions such as java.net.http.HttpConnectTimeoutException: HTTP connect timed out. That happened once while this lesson was being tested, which is exactly why timeouts and retries matter.
Status codes are not exceptions
HttpClient does not throw an exception for 404 Not Found or 500 Internal Server Error. Those are valid HTTP responses, so your code must check statusCode() itself. The main families:
| Code | Meaning | Typical reaction |
|---|---|---|
| 200, 201, 204 | success (OK, Created, No Content) | read the body |
| 400, 422 | your request was wrong | fix the data; do not retry |
| 401, 403 | not logged in / not allowed | check the API key or token |
| 404 | not found | often a normal "no result" |
| 429 | too many requests | wait, then retry |
| 500, 502, 503, 504 | server problem | retry a few times, then give up |
The next example is a small typed client for a shop API. A practice server, FakeShopServer, built with the JDK's own com.sun.net.httpserver.HttpServer, runs on localhost; it answers /pincodes/{pin}, accepts POST /orders, and has a /stock endpoint that is "busy" twice before it succeeds. (Its code is short and only for practice; real services are built with frameworks such as Spring Boot.)
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Optional;
import tools.jackson.databind.json.JsonMapper;
public class ShopClient {
record Pincode(String pin, String city, String state) {}
record NewOrder(String customer, String pin, int amount) {}
record Created(int orderId, NewOrder received) {}
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
private final JsonMapper json = JsonMapper.builder().build();
private final String baseUrl;
ShopClient(String baseUrl) {
this.baseUrl = baseUrl;
}
Optional<Pincode> findPincode(String pin) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/pincodes/" + pin))
.timeout(Duration.ofSeconds(5))
.build(); // GET is the default
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
return switch (response.statusCode()) {
case 200 -> Optional.of(json.readValue(response.body(), Pincode.class));
case 404 -> Optional.empty();
default -> throw new IOException("Pincode service returned " + response.statusCode());
};
}
Created placeOrder(NewOrder order) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/orders"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(order)))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 201) {
throw new IOException("Order failed with " + response.statusCode() + ": " + response.body());
}
System.out.println("Location: " + response.headers().firstValue("Location").orElse("-"));
return json.readValue(response.body(), Created.class);
}
public static void main(String[] args) throws Exception {
HttpServer server = FakeShopServer.start(8085);
try {
ShopClient shop = new ShopClient("http://localhost:8085");
System.out.println(shop.findPincode("124001"));
System.out.println(shop.findPincode("999999"));
Created created = shop.placeOrder(new NewOrder("Meera", "682001", 1299));
System.out.println("Created order " + created.orderId() + " for " + created.received().customer());
} finally {
server.stop(0);
}
}
}Optional[Pincode[pin=124001, city=Rohtak, state=Haryana]] Optional.empty Location: /orders/5001 Created order 5001 for Meera
This is how API clients are written in industry: one class per external service, one method per operation, records for the request and response bodies, and status codes turned into return values or exceptions in one place. Callers never see raw HTTP. A missing pincode is a normal answer (Optional.empty()), while a 500 is a real failure (an exception).
For POST, BodyPublishers.ofString(...) supplies the body, and the Content-Type: application/json header tells the server how to read it. PUT(...), DELETE() and method("PATCH", ...) work the same way. A 201 Created response often includes a Location header with the new resource's address.
Retries with exponential backoff
Busy servers answer 503 Service Unavailable or 429 Too Many Requests, and a second try a moment later often succeeds. Retrying instantly in a tight loop makes the overload worse, so wait longer after each failure: 100 ms, then 200 ms, then 400 ms. This is called exponential backoff.
static String getWithRetry(URI uri, int maxAttempts) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(5)).build();
long waitMs = 100;
for (int attempt = 1; ; attempt++) {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
boolean retryable = status == 429 || status == 502 || status == 503 || status == 504;
if (!retryable || attempt == maxAttempts) {
System.out.println("Attempt " + attempt + ": " + status + " (done)");
return response.body();
}
System.out.println("Attempt " + attempt + ": " + status + ", waiting " + waitMs + " ms");
Thread.sleep(waitMs);
waitMs *= 2; // exponential backoff
}
}Calling getWithRetry(URI.create(base + "/stock"), 4) against the practice server prints:
Attempt 1: 503, waiting 100 ms
Attempt 2: 503, waiting 200 ms
Attempt 3: 200 (done)
{"item":"ghee","stock":14}Retry only requests that are safe to repeat. A GET can be repeated freely. Retrying a POST that charges a customer could charge them twice if the first attempt actually succeeded and only the reply was lost. Payment APIs solve this with an idempotency key: a unique ID sent as a header, so the server recognises a repeated request and does not process it twice.
Many calls at once with sendAsync
sendAsync returns a CompletableFuture<HttpResponse<T>> immediately, so several requests can be in flight together. All the composition tools from CompletableFuture & Concurrency Tools apply.
List<String> pins = List.of("124001", "682001", "302001", "000000");
List<CompletableFuture<String>> calls = pins.stream()
.map(pin -> HttpRequest.newBuilder(URI.create(base + "/pincodes/" + pin)).build())
.map(req -> http.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(res -> res.statusCode() + " " + res.body()))
.toList();
calls.forEach(c -> System.out.println(c.join()));200 {"pin":"124001","city":"Rohtak","state":"Haryana"}
200 {"pin":"682001","city":"Kochi","state":"Kerala"}
200 {"pin":"302001","city":"Jaipur","state":"Rajasthan"}
404 {"error":"unknown pincode 000000"}All four requests start before the first join(), and joining in list order keeps the output in the original order. Alternatively, keep the simple blocking send and run each call on a virtual thread; both styles are fine.
Common mistakes
- Creating a new
HttpClientper request. Reuse one; it manages connection pools and threads. - No timeouts. Set both
connectTimeoutand the requesttimeout. - Assuming non-2xx throws. Check
statusCode()before parsing the body. - Retrying everything. Do not retry 400-class errors, and protect non-idempotent POSTs with an idempotency key.
- Building URLs by gluing user input. Encode query values with
URLEncoder.encode(value, StandardCharsets.UTF_8). - Logging full responses with personal data or tokens. Log the status and an ID instead.
Key points
java.net.http.HttpClient(Java 11+) sends requests; build one client and reuse it.HttpRequest.newBuilder(uri)sets method, headers, body and timeout;BodyHandlers.ofString()reads text.- HTTP error codes are normal responses; map them to return values or exceptions in one client class.
- Combine with Jackson:
writeValueAsStringfor request bodies,readValuefor responses. - Retry only transient failures (429, 502, 503, 504) with exponential backoff, and only safe or idempotent requests.
sendAsyncreturns aCompletableFuturefor running calls in parallel.
Exercise
Using JSONPlaceholder, write TodoReport that fetches /users/1 and /todos?userId=1 at the same time with sendAsync. Map the first to a record User(int id, String name, String email) and the second to List<Todo> where Todo(int id, String title, boolean completed), throwing an exception if any status is not 200. Combine both results and print one line such as Leanne Graham: 11 of 20 tasks completed.
Show solution
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;
public class TodoReport {
record Todo(int id, String title, boolean completed) {}
record User(int id, String name, String email) {}
static final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
static final JsonMapper json = JsonMapper.builder().build();
static final String BASE = "https://jsonplaceholder.typicode.com";
static CompletableFuture<String> get(String path) {
HttpRequest request = HttpRequest.newBuilder(URI.create(BASE + path))
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.build();
return http.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(res -> {
if (res.statusCode() != 200) {
throw new IllegalStateException(path + " returned " + res.statusCode());
}
return res.body();
});
}
public static void main(String[] args) {
CompletableFuture<User> user = get("/users/1")
.thenApply(body -> json.readValue(body, User.class));
CompletableFuture<List<Todo>> todos = get("/todos?userId=1")
.thenApply(body -> json.readValue(body, new TypeReference<List<Todo>>() {}));
String report = user.thenCombine(todos, (u, list) -> {
long done = list.stream().filter(Todo::completed).count();
return u.name() + ": " + done + " of " + list.size() + " tasks completed";
}).join();
System.out.println(report);
}
}Leanne Graham: 11 of 20 tasks completed
The real user JSON has many more fields (address, phone, company), but Jackson 3 ignores unknown properties, so the small User record is enough. Because Jackson 3 exceptions are unchecked, readValue can be called directly inside thenApply.
Quick Quiz
1. What does HttpClient.send do when the server answers 404?
HTTP error codes are valid responses; your code must check the status.
2. Why should you set a timeout on every request?
Timeouts turn a hang into a clear exception you can handle or retry.
3. Which response is a good candidate for an automatic retry with backoff?
503 usually means a temporary overload; 400-class errors will fail again the same way.
4. What does sendAsync return?
It starts the request and immediately returns a future you can compose and join.
Learn Java with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
