Apache HttpClient 5 Examples: Consume REST APIs in Java

Apache HttpClient 5 examples for REST APIs in Java with GET, POST JSON, PUT, DELETE, headers, basic auth, timeouts, pooling and a java.net.http comparison.

Request flow in Apache HttpClient 5 from ClassicRequestBuilder through the connection pool to the response handler

Apache HttpClient 5 is an HTTP client library for Java that sends requests such as GET, POST, PUT and DELETE to a REST API and gives us the status code, headers and body of the response. Its classic API is blocking, and the class we work with is CloseableHttpClient, which manages a pool of connections and closes them when we close the client.

We use HttpClient 5 to call other services from a Java app, such as a payment provider, an internal microservice or a public API. It handles timeouts, connection pooling, authentication, cookies and redirects, and Spring’s RestClient and RestTemplate can use it as their HTTP engine.

The following example sends a GET request to a small notes API that runs on our machine and reads the JSON body as a String.

HttpServer server = startNotesServer();                   // local test API on port 8080
CloseableHttpClient client = HttpClients.createDefault();

ClassicHttpRequest get = ClassicRequestBuilder.get("http://localhost:8080/notes/1").build();
String body = client.execute(get, new BasicHttpClientResponseHandler());   // {"id":1,"text":"buy milk"}

client.close();
server.stop(0);

Notice that execute() takes a response handler, which reads the response and releases the connection for us. We build the local API first, send JSON with POST, set headers, authentication and timeouts, handle errors, and compare HttpClient 5 with the JDK’s own java.net.http.HttpClient.

1. Apache HttpClient 5 Setup

Add the httpclient5 dependency. It brings httpcore5 with it and logs through SLF4J. The examples use HttpClient 5.6.4, the latest stable release, on Java 25, and they map JSON with Jackson 3.

<dependency>
    <groupId>org.apache.httpcomponents.client5</groupId>
    <artifactId>httpclient5</artifactId>
    <version>5.6.4</version>
</dependency>
<dependency>
    <groupId>tools.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>3.1.7</version>
</dependency>

HttpClient 5 is not a drop-in update of HttpClient 4. The packages moved from org.apache.http to org.apache.hc.client5 and org.apache.hc.core5, the status code comes from getCode() instead of getStatusLine().getStatusCode(), and timeouts are Timeout objects instead of milliseconds. Version 4.5 is in maintenance mode, so new code uses version 5.

2. A Local REST API for the Examples

Examples that call a public website break when the site changes or the network is down. So we run a tiny notes API with com.sun.net.httpserver.HttpServer, which is part of the JDK. In a JUnit test, we would start it in a @BeforeAll method and stop it in @AfterAll.

static HttpServer startNotesServer() throws IOException {
    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 8080), 0);
    server.createContext("/notes", exchange -> {
        String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
        String route = exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath();
        switch (route) {
            case "GET /notes/1" -> respond(exchange, 200, "{\"id\":1,\"text\":\"buy milk\"}");
            case "POST /notes" -> respond(exchange, 201, body.replace("{", "{\"id\":3,"));
            case "PUT /notes/1" -> respond(exchange, 200, body);
            case "DELETE /notes/1" -> respond(exchange, 204, null);
            default -> respond(exchange, 404, "{\"error\":\"note not found\"}");
        }
    });
    server.createContext("/search", exchange -> {
        String requestId = exchange.getRequestHeaders().getFirst("X-Request-Id");
        respond(exchange, 200, "{\"query\":\"" + exchange.getRequestURI().getQuery() + "\",\"requestId\":\"" + requestId + "\"}");
    });
    server.createContext("/account", exchange -> {
        String expected = "Basic " + Base64.getEncoder().encodeToString("lokesh:secret".getBytes(StandardCharsets.UTF_8));
        if (expected.equals(exchange.getRequestHeaders().getFirst("Authorization"))) {
            respond(exchange, 200, "{\"user\":\"lokesh\"}");
        } else {
            exchange.getResponseHeaders().add("WWW-Authenticate", "Basic realm=\"notes\"");
            respond(exchange, 401, "{\"error\":\"login required\"}");
        }
    });
    server.createContext("/slow", exchange -> {
        try {
            new CountDownLatch(1).await(2, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        respond(exchange, 200, "{}");
    });
    server.start();
    return server;
}
static void respond(HttpExchange exchange, int status, String json) throws IOException {
    if (json == null) {
        exchange.sendResponseHeaders(status, -1);
    } else {
        byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().add("Content-Type", "application/json");
        exchange.sendResponseHeaders(status, bytes.length);
        exchange.getResponseBody().write(bytes);
    }
    exchange.close();
}

The API answers GET, POST, PUT and DELETE on /notes, echoes the query and a header on /search, wants basic authentication on /account and takes two seconds to answer on /slow. Every later example starts it with startNotesServer() and stops it with server.stop(0).

3. Sending a GET Request

A request has three parts in HttpClient 5. ClassicRequestBuilder builds the request, execute() sends it, and a HttpClientResponseHandler turns the response into the value we want. The handler runs while the connection is still open, and after it returns, HttpClient consumes the rest of the body and puts the connection back into the pool.

Request flow in Apache HttpClient 5 from ClassicRequestBuilder through the connection pool to the response handler
The response handler reads the response while the connection is open, then HttpClient returns the connection to the pool

The built-in BasicHttpClientResponseHandler returns the body as a String for status codes below 300 and throws HttpResponseException for all others. When we need the status code too, we write the handler as a lambda.

record ApiResponse(int status, String body) {}
HttpServer server = startNotesServer();
CloseableHttpClient client = HttpClients.createDefault();
HttpClientResponseHandler<ApiResponse> toApiResponse = response -> new ApiResponse(
        response.getCode(), response.getEntity() == null ? "" : EntityUtils.toString(response.getEntity()));

ApiResponse found = client.execute(ClassicRequestBuilder.get("http://localhost:8080/notes/1").build(), toApiResponse);
int foundStatus = found.status();                             // 200
ApiResponse missing = client.execute(ClassicRequestBuilder.get("http://localhost:8080/notes/42").build(), toApiResponse);
String missingBody = missing.body();                          // {"error":"note not found"}

client.close();
server.stop(0);

To read the body into a Java object, we parse the String with Jackson. A record works as the target type in Jackson 3 without any annotations.

record Note(Integer id, String text) {}

record NewNote(String text) {}
HttpServer server = startNotesServer();
CloseableHttpClient client = HttpClients.createDefault();
JsonMapper mapper = JsonMapper.shared();

String json = client.execute(ClassicRequestBuilder.get("http://localhost:8080/notes/1").build(), new BasicHttpClientResponseHandler());
Note note = mapper.readValue(json, Note.class);               // Note[id=1, text=buy milk]

client.close();
server.stop(0);

Older examples call execute(request) without a handler and get a CloseableHttpResponse, but that method is deprecated in HttpClient 5. It leaves the caller responsible for consuming the body and closing the response, and a forgotten close() keeps the connection out of the pool until the pool runs empty.

4. Sending a POST Request With JSON

For a POST request, we serialize the object to JSON and send it as a StringEntity with ContentType.APPLICATION_JSON. The content type sets the Content-Type header and the UTF-8 charset in one step.

HttpServer server = startNotesServer();
CloseableHttpClient client = HttpClients.createDefault();
JsonMapper mapper = JsonMapper.shared();

String requestJson = mapper.writeValueAsString(new NewNote("water plants"));   // {"text":"water plants"}
ClassicHttpRequest post = ClassicRequestBuilder.post("http://localhost:8080/notes")
        .setEntity(new StringEntity(requestJson, ContentType.APPLICATION_JSON))
        .build();
HttpClientResponseHandler<Note> createdNote = response -> {
    if (response.getCode() != 201) {
        throw new HttpResponseException(response.getCode(), "Note was not created");
    }
    return mapper.readValue(EntityUtils.toString(response.getEntity()), Note.class);
};
Note created = client.execute(post, createdNote);             // Note[id=3, text=water plants]

client.close();
server.stop(0);

We can see that the handler checks the expected status before parsing. A 400 or 500 response often has an error body in a different shape, and parsing it as a Note would hide the real problem behind a JSON error. For HTML forms, UrlEncodedFormEntity with a list of BasicNameValuePair objects sends application/x-www-form-urlencoded data instead.

5. PUT and DELETE Requests

PUT and DELETE work the same way, with ClassicRequestBuilder.put() and delete(). A successful DELETE often answers with 204 No Content, which has no body, so our handler from section 3 returns an empty String for it.

HttpServer server = startNotesServer();
CloseableHttpClient client = HttpClients.createDefault();
HttpClientResponseHandler<ApiResponse> toApiResponse = response -> new ApiResponse(
        response.getCode(), response.getEntity() == null ? "" : EntityUtils.toString(response.getEntity()));

ClassicHttpRequest put = ClassicRequestBuilder.put("http://localhost:8080/notes/1")
        .setEntity(new StringEntity("{\"id\":1,\"text\":\"buy oat milk\"}", ContentType.APPLICATION_JSON))
        .build();
ApiResponse updated = client.execute(put, toApiResponse);
String updatedBody = updated.body();                          // {"id":1,"text":"buy oat milk"}
ApiResponse deleted = client.execute(ClassicRequestBuilder.delete("http://localhost:8080/notes/1").build(), toApiResponse);
int deletedStatus = deleted.status();                         // 204

client.close();
server.stop(0);

6. Headers, Query Parameters and Basic Authentication

Most APIs need a few extras on each request, such as an Accept header, a correlation id or a token. The builder adds headers with addHeader() and query parameters with addParameter(), and it encodes parameter values, so spaces and special characters arrive intact.

HttpServer server = startNotesServer();
CloseableHttpClient client = HttpClients.createDefault();

ClassicHttpRequest search = ClassicRequestBuilder.get("http://localhost:8080/search")
        .addParameter("q", "milk & eggs")
        .addHeader(HttpHeaders.ACCEPT, "application/json")
        .addHeader("X-Request-Id", "r-17")
        .build();
String result = client.execute(search, new BasicHttpClientResponseHandler());   // {"query":"q=milk & eggs","requestId":"r-17"}

client.close();
server.stop(0);

For basic authentication, we give the client a credentials provider. The client sends the first request without credentials, gets a 401 response with a WWW-Authenticate header, and repeats the request with the user name and password for that host.

HttpServer server = startNotesServer();
CloseableHttpClient client = HttpClients.custom()
        .setDefaultCredentialsProvider(CredentialsProviderBuilder.create()
                .add(new HttpHost("localhost", 8080), "lokesh", "secret".toCharArray())
                .build())
        .build();

String account = client.execute(ClassicRequestBuilder.get("http://localhost:8080/account").build(), new BasicHttpClientResponseHandler());   // {"user":"lokesh"}

client.close();
server.stop(0);

Basic authentication sends the password in Base64, which anyone on the network can decode, so we use it only over HTTPS. For bearer tokens such as OAuth 2 access tokens, we add the header ourselves with addHeader(HttpHeaders.AUTHORIZATION, “Bearer ” + token).

7. Timeouts and Connection Pooling

HttpClient 5 has no response timeout by default, so a hanging server can block the calling thread forever. The defaults are a 3-minute connect timeout, a 3-minute wait for a pooled connection and no limit on waiting for data. Timeouts come in three kinds, and HttpClient 5.2 and later configure them in two places.

TimeoutWhat it limitsWhere to set it
Connect timeoutOpening the TCP connectionConnectionConfig.setConnectTimeout() on the connection manager
Socket or response timeoutWaiting for data from the serverConnectionConfig.setSocketTimeout(), or RequestConfig.setResponseTimeout() per request
Connection request timeoutWaiting for a free connection from the poolRequestConfig.setConnectionRequestTimeout()
HttpServer server = startNotesServer();
PoolingHttpClientConnectionManager pool = PoolingHttpClientConnectionManagerBuilder.create()
        .setMaxConnTotal(50)
        .setMaxConnPerRoute(10)
        .setDefaultConnectionConfig(ConnectionConfig.custom()
                .setConnectTimeout(Timeout.ofSeconds(2))
                .setSocketTimeout(Timeout.ofSeconds(5))
                .build())
        .build();
CloseableHttpClient client = HttpClients.custom()
        .setConnectionManager(pool)
        .setDefaultRequestConfig(RequestConfig.custom()
                .setResponseTimeout(Timeout.ofSeconds(1))
                .setConnectionRequestTimeout(Timeout.ofSeconds(3))
                .build())
        .build();

String quick = client.execute(ClassicRequestBuilder.get("http://localhost:8080/notes/1").build(), new BasicHttpClientResponseHandler());   // {"id":1,"text":"buy milk"}
String slow = client.execute(ClassicRequestBuilder.get("http://localhost:8080/slow").build(), new BasicHttpClientResponseHandler());       // SocketTimeoutException: Read timed out

client.close();
server.stop(0);

The pool keeps open connections and reuses them for the next request to the same host, which saves the TCP and TLS handshakes. So we create one client per app, or one per remote service with its own settings, and share it between threads, because CloseableHttpClient is thread-safe. The setter RequestConfig.setConnectTimeout() from older examples still compiles, but it is deprecated in favor of ConnectionConfig.

8. Handling Errors

A call to a remote API can fail in two different ways, and the app reacts differently to each. The server can answer with an error status, or the request can fail before any response arrives.

  • An error status such as 404 or 503 reaches the response handler. BasicHttpClientResponseHandler turns it into an HttpResponseException with getStatusCode(), and our own handler can map it to a result or a domain exception.
  • A network failure throws an IOException subclass from execute(), such as ConnectException when nothing listens on the port, SocketTimeoutException after a timeout, or UnknownHostException for a wrong host name.
HttpServer server = startNotesServer();
CloseableHttpClient client = HttpClients.createDefault();

String notFound = client.execute(ClassicRequestBuilder.get("http://localhost:8080/notes/42").build(), new BasicHttpClientResponseHandler());   // HttpResponseException: status code: 404, reason phrase: Not Found
server.stop(0);
String refused = client.execute(ClassicRequestBuilder.get("http://localhost:8080/notes/1").build(), new BasicHttpClientResponseHandler());     // HttpHostConnectException: Connect to http://localhost:8080 failed: Connection refused

client.close();

HttpHostConnectException extends ConnectException, so a catch (ConnectException e) block covers it. Retrying makes sense for timeouts and 503 responses, and HttpClient 5 by default retries an idempotent request such as GET once after some I/O errors and after 429 and 503 responses, but not after a timeout. A POST is not retried, because the server may have processed it already.

9. Wrapping the Calls in a NotesClient Class

In a task app, several screens show the user’s notes from a separate notes service. Every screen that needs notes should not build requests and parse JSON itself, so we put the HTTP calls into one client class with typed methods. The class owns one CloseableHttpClient with timeouts and closes it when the app shuts down.

static class NotesClient implements AutoCloseable {
    private final CloseableHttpClient http;
    private final JsonMapper mapper = JsonMapper.shared();
    private final String baseUrl;

    NotesClient(String baseUrl) {
        this.baseUrl = baseUrl;
        this.http = HttpClients.custom()
                .setDefaultRequestConfig(RequestConfig.custom().setResponseTimeout(Timeout.ofSeconds(3)).build())
                .build();
    }

    Optional<Note> find(int id) throws IOException {
        return http.execute(ClassicRequestBuilder.get(baseUrl + "/notes/" + id).build(), response -> switch (response.getCode()) {
            case 200 -> Optional.of(mapper.readValue(EntityUtils.toString(response.getEntity()), Note.class));
            case 404 -> Optional.<Note>empty();
            default -> throw new HttpResponseException(response.getCode(), "Notes service failed");
        });
    }

    Note create(String text) throws IOException {
        String json = mapper.writeValueAsString(Map.of("text", text));
        ClassicHttpRequest post = ClassicRequestBuilder.post(baseUrl + "/notes")
                .setEntity(new StringEntity(json, ContentType.APPLICATION_JSON))
                .build();
        return http.execute(post, response -> mapper.readValue(EntityUtils.toString(response.getEntity()), Note.class));
    }

    @Override
    public void close() throws IOException {
        http.close();
    }
}
HttpServer server = startNotesServer();
NotesClient notes = new NotesClient("http://localhost:8080");

Optional<Note> first = notes.find(1);                         // Optional[Note[id=1, text=buy milk]]
Optional<Note> unknown = notes.find(42);                      // Optional.empty
Note added = notes.create("book flights");                    // Note[id=3, text=book flights]

notes.close();
server.stop(0);

Callers see Optional and Note, not HTTP details, and a 404 becomes an empty result instead of an exception. In production code, we create the client once, for example as a Spring bean, and use it in a try-with-resources block only in short-lived tools and tests.

10. Apache HttpClient vs java.net.http.HttpClient

Since Java 11, the JDK has its own java.net.http.HttpClient, and since Java 21 it is AutoCloseable. It needs no dependency, supports HTTP/2 and has a non-blocking API with CompletableFuture. With the JDK client, the same GET request sets the connect timeout on the builder and the response timeout on the request.

HttpServer server = startNotesServer();
HttpClient jdkClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();

HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:8080/notes/1")).timeout(Duration.ofSeconds(5)).GET().build();
HttpResponse<String> response = jdkClient.send(request, HttpResponse.BodyHandlers.ofString());
int jdkStatus = response.statusCode();                        // 200
String jdkBody = response.body();                             // {"id":1,"text":"buy milk"}

jdkClient.close();
server.stop(0);

The JDK client does not throw for 404 or 500 responses, so we check statusCode() ourselves. Both clients are good choices, and the decision depends on the features the app needs.

FeatureApache HttpClient 5java.net.http.HttpClient
Dependencyhttpclient5 and httpcore5None, part of the JDK
Connection pool settingsMax total, max per route, time to live, idle evictionInternal pool, few settings
AuthenticationBasic, Digest, Bearer; NTLM and SPNEGO are deprecatedBasic through Authenticator, others by header
CookiesBasicCookieStoreCookieManager
Retries and redirectsConfigurable retry strategy and redirect handlingRedirects only, no retries
Async APISeparate async clientsendAsync() with CompletableFuture
HTTP/2Async client onlyYes, default when the server supports it

We pick the JDK client for simple calls without extra dependencies, and HttpClient 5 when we need fine control over pooling, retries or proxy authentication. Spring applications often call APIs through Spring RestClient, which can run on either client.

11. Apache HttpClient 5 FAQs

HttpClient 5 questions often come from code written for version 4 and from examples that use deprecated methods.

11.1. Is execute(request) deprecated in HttpClient 5?

Yes. The execute() methods of CloseableHttpClient that return a CloseableHttpResponse are deprecated in the 5.x classic API. Use the variants that take an HttpClientResponseHandler, which consume the body and release the connection after the handler returns.

11.2. How do we set a timeout in Apache HttpClient 5?

Set the connect and socket timeouts in a ConnectionConfig on the connection manager, and the response and connection request timeouts in a RequestConfig, as in section 7. All values are Timeout objects, such as Timeout.ofSeconds(5).

11.3. How do we send JSON with a POST request?

Create a StringEntity with the JSON text and ContentType.APPLICATION_JSON, set it with ClassicRequestBuilder.post(url).setEntity(entity), and execute the request with a response handler. Jackson’s writeValueAsString() creates the JSON from an object.

11.4. Should we create a new HttpClient for every request?

No. Creating a client per request throws away the connection pool, so every call pays for a new TCP and TLS handshake. Create one CloseableHttpClient, share it between threads and close it when the app stops.

11.5. How do we migrate from HttpClient 4 to HttpClient 5?

Change the dependency to org.apache.httpcomponents.client5:httpclient5 and the imports from org.apache.http to org.apache.hc.client5 and org.apache.hc.core5. Replace getStatusLine().getStatusCode() with getCode(), millisecond timeouts with Timeout objects, and execute(request) with a response handler. The project’s migration guide lists the remaining changes.

12. Conclusion

Apache HttpClient 5 sends REST requests with ClassicRequestBuilder and reads responses with a response handler, which also returns the connection to the pool. POST and PUT requests carry JSON as a StringEntity, headers and query parameters go on the builder, and a credentials provider handles basic authentication.

A production client needs timeouts, because there are none by default, and one shared client instance for the whole app. When the app needs nothing beyond plain requests, the JDK’s java.net.http.HttpClient does the same job without a dependency.

13. References

Happy Learning !!

Source Code on Github

Leave a Comment

  1. Hi,
    Thanks for sharing the example on 2) GET API Example with Apache HttpClient.
    I have created a client program by following your example 2) GET API Example with Apache HttpClient.
    Please guide me what and how do I implement Junit for this scenario.

    • In My client program, I am invoking some external service calls. third party URLs. I do not have web service provider implementation part.

  2. Thanks for sharing the sample code on RESTFul service implementation & httpclient. I was writing the RESTFul services for the first time and your sample code really helped me a lot.

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.