Making HTTP calls in Java & Spring: overview, tips and pitfalls

Anton Tkachenko
Duda
Published in
8 min readJun 11, 2024

Introduction

The majority of modern applications make HTTP calls to other systems. In this series of articles, I want to share my thoughts and experience (positive and negative) on what should be the approach of choosing the library to make HTTP calls in your Spring Boot-based projects.

Here are the links to the articles on Medium:

Specifically, I am targeting the case of a big project with multiple teams and multiple services. In such a case, it is important to have a consistent approach to common tasks like making HTTP calls so that the code is straightforward to understand and maintain by all the teams.

In this article, I will cover high-level topics with occasional dive-ins and provide examples of various approaches to making HTTP calls in Java, and how to test them. If you are going to check out the source code, please don’t blame me for the fact that .gradlew test the task fails: I did not invest time into making the project's build green (mostly because I reused the same ports). But every individual test will pass if you run it.

I will not be covering the topic of reactive/async clients, as it’s a separate topic. Also, most applications still use synchronous clients (and other blocking processing). And as Java 21 is already out, it might be that virtual threads will be a game-changer for choosing sync vs. async clients.

General agenda for the series:

  • Options for making HTTP calls in Java
    — Code examples for each option
    — Testing performed HTTP calls
  • Spring’s interfaces for making HTTP calls
    — RestTemplate, RestClient, ***Template and ***Operations patterns
    — Spring’s test kit for RestTemplate / RestClient
  • What’s under the facade of RestTemplate
    ClientHttpRequestFactory abstractions
    — How ClientHttpRequestFactory is used in Spring's mocks/test-kits
  • Configuring Apache Client as an implementation of ClientHttpRequestFactory
    — Connection pooling
    — Timeouts
  • Adding observability for HTTP client
    — Monitoring connection pool
    — Monitoring core metrics of outgoing requests
  • A deeper glance into non-happy-path scenarios:
    — handling unexpected network behavior
    — setting hard timeouts, failing fast

Part 1 — Options and testing

Java has several libraries to make HTTP calls. Here are some of them:

  • java.net.HttpURLConnection - the standard JDK means for making HTTP calls. it's unlikely that you will use it in a modern application, but some legacy applications might still use it.
  • Java 11 HttpClient — a relatively new addition to the JDK. It's a modern and flexible solution
  • Apache HttpClient (org.apache.httpcomponents.client5:httpclient5) - a popular library that has been around for a long time
  • OkHttp (com.squareup.okhttp3:okhttp) - a modern and efficient library
  • Jersey (org.glassfish.jersey.core:jersey-client) - a JAX-RS implementation of HTTP client
  • Retrofit (com.squareup.retrofit2:retrofit) - a type-safe HTTP client for Android and Java
  • … and some others that I will not cover here

Further, I’ll show quick code examples for each of the libraries.

Also, spring web module provides a RestTemplate class that can be used to make HTTP calls with convenient "template" methods. Most of the article will be dedicated to Spring's built-in API for making HTTP calls.

Testing making HTTP calls

No matter which library you choose, it’s important to test that it works as expected (and also non-happy-path scenarios).

First, a crucial rule on how NOT to test HTTP calls:

DO NOT USE MOCKITO TO MOCK INVOCATIONS OF HTTP CLIENTS

Few reasons for this:

  • It’s generally not a good idea to mock something you don’t own. Libraries/components that are designed to be used by other developers should either be tested in integration with FULL real implementation and infrastructure or should provide mock implementations that are compatible with the real one in most cases.
  • It’s hard to maintain such tests: in many cases, you can do a legal refactoring that will break the tests because of the way how the mocks are set up to expect specific methods with specific arguments (e.g. if API has multiple ways to achieve the same result, you might need to change the test to reflect the change)
  • such tests are very limited in exploring the real behaviour of the system.
  • as a quick example, when you bypass the real deserialization logic, bugs in production may happen because you forgot to add no-arg constructor to the model class, or forgot to add annotation for naming strategy, etc.
  • also, exploring non-2xx responses may become a guessing game: do I need to check the status code, or does the client throw an exception (and what kind of exception)?
  • some behaviour is just too painful to mock via Mockito (like content-type that comes from annotations)

A good way to test HTTP calls is to use a mock server. Two popular options are WireMock and MockServer, in this article, I'll use https://www.mock-server.com/

This is a very convenient library that also integrates with JUnit 5 to reduce the boilerplate code.

// https://mvnrepository.com/artifact/org.mock-server/mockserver-netty
testImplementation 'org.mock-server:mockserver-junit-jupiter:5.15.0'

Here is the example of the base-test class that is used in the examples below:

import lombok.RequiredArgsConstructor;
import org.junit.jupiter.api.BeforeEach;
import org.mockserver.client.MockServerClient;
import org.mockserver.junit.jupiter.MockServerSettings;

@MockServerSettings(ports = 1090)
@RequiredArgsConstructor
public class TestWithMockServer {
protected final MockServerClient mockServer;
protected record SampleResponseModel(String name, int age) {
}

protected record ErrorResponseModel(String error) {
}
@BeforeEach
void reset() {
mockServer.reset();
// expect GET /some-endpoint
// and respond with 200 OK + response body
mockServer.when(
org.mockserver.model.HttpRequest.request()
.withMethod("GET")
.withPath("/some-endpoint")
).respond(
org.mockserver.model.HttpResponse.response()
.withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"name\":\"John\",\"age\":25}")
);
// + configure non-ok responses
mockServer.when(
org.mockserver.model.HttpRequest.request()
.withMethod("GET")
.withPath("/bad-req-endpoint")
).respond(
org.mockserver.model.HttpResponse.response()
.withStatusCode(400)
.withHeader("Content-Type", "application/json")
.withBody("{\"error\":\"Bad request\"}")
);
}
}

The general explanation of the setup:

  • @MockServerSettings(ports = 1090) - will start the mock server (via MockServerExtension that is used under the hood) on port 1090
  • @RequiredArgsConstructor + protected final MockServerClient mockServer - will inject the mock server client into the test class
  • @BeforeEach void reset() - will reset the mock server before each test
  • mockServer.when(...).respond(...) - will set up the expectation for the mock server for a simple call
  • each test might also add additional expectations when needed

Code examples for each option

java.net.HttpURLConnection

@Test
void goodOldDirectViaJavaConnection() throws Exception {
URL url = new URL("http://localhost:1090/some-endpoint");
java.net.HttpURLConnection connection = (java.net.HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "application/json");
connection.connect();
int responseCode = connection.getResponseCode();
assertThat(responseCode).isEqualTo(200);
String responseBody = new String(connection.getInputStream().readAllBytes());
connection.disconnect();
var asObject = new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(responseBody, SampleResponseModel.class);
assertThat(asObject).isEqualTo(new SampleResponseModel("John", 25));
}

Note that we have to manually parse the response body into an object by using any JSON library (in this case, Jackson).

Java 11 HttpClient

@Test
void viaJava11HttpClient() throws Exception {
var client = java.net.http.HttpClient.newHttpClient();
var request = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create("http://localhost:1090/some-endpoint"))
.header("Accept", "application/json")
.build();
HttpResponse<String> response = client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
var asObject = new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(response.body(), SampleResponseModel.class);
assertThat(asObject).isEqualTo(new SampleResponseModel("John", 25));
// now making the call to the bad-req endpoint
HttpRequest badRequest = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create("http://localhost:1090/bad-req-endpoint"))
.header("Accept", "application/json")
.build();
HttpResponse<String> badResponse = client.send(badRequest, java.net.http.HttpResponse.BodyHandlers.ofString());
assertThat(badResponse.statusCode()).isEqualTo(400);
assertThat(badResponse.body()).isEqualTo("{\"error\":\"Bad request\"}");
}

In comparison with java.net.HttpURLConnection, the java.net.http.HttpClient requires less boilerplate code. However, you still need to manually parse the response body into an object and handle non-2xx responses.

Apache HttpClient

@Test
void withApacheHttpClient() throws Exception {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
org.apache.hc.client5.http.classic.methods.HttpGet request = new org.apache.hc.client5.http.classic.methods.HttpGet("http://localhost:1090/some-endpoint");
request.addHeader("Accept", "application/json");
CloseableHttpResponse response = client.execute(request);
assertThat(response.getCode()).isEqualTo(200);
var asObject = new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(response.getEntity().getContent(), SampleResponseModel.class);
assertThat(asObject).isEqualTo(new SampleResponseModel("John", 25));
// now making the call to the bad-req endpoint
org.apache.hc.client5.http.classic.methods.HttpGet badRequest = new org.apache.hc.client5.http.classic.methods.HttpGet("http://localhost:1090/bad-req-endpoint");
CloseableHttpResponse badResponse = client.execute(badRequest);
assertThat(badResponse.getCode()).isEqualTo(400);
assertThat(new com.fasterxml.jackson.databind.ObjectMapper()
.readTree(badResponse.getEntity().getContent()).get("error").asText()).isEqualTo("Bad response for bad request");
}
}

Here I will not dive into the Apache HttpClient, as it’s a big library with a lot of features, but you can see that it adds more abstractions over the HTTP protocol (like CloseableHttpClient, HttpGet, CloseableHttpResponse).

OkHttp

@Test
void withOkHttpClient() throws Exception {
OkHttpClient client = new okhttp3.OkHttpClient();

okhttp3.Request request = new okhttp3.Request.Builder()
.url("http://localhost:1090/some-endpoint")
.addHeader("Accept", "application/json")
.build();

okhttp3.Response response = client.newCall(request).execute();
assertThat(response.code()).isEqualTo(200);

var asObject = new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(response.body().string(), SampleResponseModel.class);
assertThat(asObject).isEqualTo(new SampleResponseModel("John", 25));

// now making the call to the bad-req endpoint
okhttp3.Request badRequest = new okhttp3.Request.Builder()
.url("http://localhost:1090/bad-req-endpoint")
.build();
okhttp3.Response badResponse = client.newCall(badRequest).execute();
assertThat(badResponse.code()).isEqualTo(400);
assertThat(new com.fasterxml.jackson.databind.ObjectMapper()
.readTree(badResponse.body().string()).get("error").asText()).isEqualTo("Bad response for bad request");
}

As you can see, OkHttp is very similar to Java 11 HttpClient and Apache HttpClient in terms of the amount of boilerplate code.

Jersey

@Test
void withJerseyClient() {
try (var client = jakarta.ws.rs.client.ClientBuilder.newClient()) {
SampleResponseModel response = client.target("http://localhost:1090/some-endpoint")
.request()
.get(SampleResponseModel.class);

assertThat(response).isEqualTo(new SampleResponseModel("John", 25));

// making the call to the bad-req endpoint will throw jakarta.ws.rs.BadRequestException
Assertions.assertThatExceptionOfType(
jakarta.ws.rs.BadRequestException.class
).isThrownBy(
() -> client.target("http://localhost:1090/bad-req-endpoint")
.request()
.get(ErrorResponseModel.class)
).withMessage("HTTP 400 Bad Request")
.extracting(e -> e.getResponse().readEntity(ErrorResponseModel.class))
.isEqualTo(new ErrorResponseModel("Bad response for bad request"));
}
}

Jersey is a JAX-RS implementation, so it’s very convenient to use if you are already using JAX-RS for the web layer (and not using Spring-Web). Also, unlike the previous examples, it has built-in support for parsing the response body into an object, which is very convenient. Also unlike most of the previous examples, it throws an exception for non-2xx responses, and this provides some consistency when writing application code. E.g., you write your code to expect a successful outcome, and if something does not go as expected, you will get an exception that you can handle (and not a silent swallowed failure).

Also, it’s worth mentioning that Jersey has proxy-extension implementation 'org.glassfish.jersey.ext:jersey-proxy-client' for creating a proxy for an interface that represents the API. Here is an example of how to use it:

@Test
void withJerseyClient_viaApiInterfaceProxy() {

@Produces("application/json")
@Consumes("application/json")
interface RemoteApi {
@GET
@Path("/some-endpoint")
SampleResponseModel getOurDomainModel();

@GET
@Path("/bad-req-endpoint")
SampleResponseModel getBadResponse();
}

var target = jakarta.ws.rs.client.ClientBuilder.newClient().target("http://localhost:1090");

var ourServiceProxy = WebResourceFactory.newResource(RemoteApi.class, target);

SampleResponseModel response = ourServiceProxy.getOurDomainModel();

assertThat(response).isEqualTo(new SampleResponseModel("John", 25));

// making the call to the bad-req endpoint will throw jakarta.ws.rs.BadRequestException
Assertions.assertThatExceptionOfType(
jakarta.ws.rs.BadRequestException.class
).isThrownBy(
() -> ourServiceProxy.getBadResponse()
).withMessage("HTTP 400 Bad Request")
.extracting(e -> e.getResponse().readEntity(ErrorResponseModel.class))
.isEqualTo(new ErrorResponseModel("Bad response for bad request"));
}

Retrofit

@Test
void withRetrofit() throws Exception {
interface RemoteRetrofitService {
@retrofit2.http.GET("/some-endpoint")
retrofit2.Call<SampleResponseModel> getOurDomainModel();

@retrofit2.http.GET("/bad-req-endpoint")
retrofit2.Call<SampleResponseModel> getBadResponse();
}

retrofit2.Retrofit retrofit = new retrofit2.Retrofit.Builder()
.baseUrl("http://localhost:1090")
.addConverterFactory(GsonConverterFactory.create())
.build();
RemoteRetrofitService service = retrofit.create(RemoteRetrofitService.class);
retrofit2.Call<SampleResponseModel> call = service.getOurDomainModel();
retrofit2.Response<SampleResponseModel> response = call.execute();
assertThat(response.code()).isEqualTo(200);
assertThat(response.body()).isEqualTo(new SampleResponseModel("John", 25));

// non-ok responses still need to be handled
retrofit2.Call<SampleResponseModel> badCall = service.getBadResponse();
retrofit2.Response<SampleResponseModel> badResponse = badCall.execute();
assertThat(badResponse.code()).isEqualTo(400);
}

Like Jersey, Retrofit has a type-safe HTTP client, which creates a type-safe proxy for the API you define. As you can see, it’s very convenient to use, but it also requires some additional setup (like defining the interface and the Retrofit object which acts as a factory for creating the actual HTTP client).

The short conclusion of Part 1

No matter which library you choose (except for direct usage of java.net.HttpURLConnection :) ), you can achieve the expected behaviour of your system. Some libraries have more complicated features and APIs, and some are more lightweight, but for the simple cases, they all work well. Today the internet is full of examples and tutorials for each of the libraries, so you will find something that matches your case. Also, many developers, use AI tools (or at least ChatGPT)— it's not a big deal to achieve your goal with any of the libraries.

However, there are some things to consider when choosing the solution:

  • whether one line of your action in human-readable language is represented by one line of code in the library
  • whether the library is actively maintained (all the mentioned libraries are, but it’s not always the case)
  • whether it supports serialization/deserialization of the request/response bodies as part of the API
  • whether it has additional test-kit features to make testing easier
  • whether it supports interface/”typed” proxy APIs

--

--