1. Overview

WebClient is an interface that simplifies the process of performing HTTP requests. Unlike RestTemplate, it’s a reactive and non-blocking client that can consume and manipulate HTTP responses. Though it’s designed to be non-blocking it can also be used in a blocking scenario.

In this tutorial, we’ll dive into key methods from the WebClient interface, including retrieve(), exchangeToMono(), and exchangeToFlux(). Also, we’ll explore the differences and similarities between these methods, and look at examples to showcase different use cases. Additionally, we’ll use the JSONPlaceholder API to fetch user data.

2. Example Setup

To begin, let’s bootstrap a Spring Boot application and add the spring-boot-starter-webflux dependency to the pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <version>3.2.4</version>
</dependency>

This dependency provides the WebClient interface, enabling us to perform HTTP requests.

Also, let’s see a sample GET response from a request to https://jsonplaceholder.typicode.com/users/1:

{
  "id": 1,
  "name": "Leanne Graham",
// ...
}

Furthermore, let’s create a POJO class named User:

class User {

    private int id;
    private String name;

   // standard constructor,getter, and setter

}

The JSON response from the JSONPlaceholder API will be deserialized and mapped to an instance of a User class.

Finally, let’s create an instance of WebClient with the base URL:

WebClient client = WebClient.create("https://jsonplaceholder.typicode.com/users");

Here, we define the base URL for HTTP requests.

3. The exchange() Method

The exchange() method returns ClientResponse directly, thereby providing access to the HTTP status code, headers, and response body. Simply put, the ClientResponse represents an HTTP response returned by WebClient.

However, this method was deprecated since Spring version 5.3 and has been replaced by the exchangeToMono() or exchangeToFlux() method, based on what we emit. The two methods allow us to decode responses based on the response status.

3.1. Emitting a Mono

Let’s see an example that uses exchangeToMono() to emit a Mono:

@GetMapping("/user/exchange-mono/{id}")
Mono<User> retrieveUsersWithExchangeAndError(@PathVariable int id) {
    return client.get()
      .uri("/{id}", id)
      .exchangeToMono(res -> {
          if (res.statusCode().is2xxSuccessful()) {
              return res.bodyToMono(User.class);
          } else if (res.statusCode().is4xxClientError()) {
              return Mono.error(new RuntimeException("Client Error: can't fetch user"));
          } else if (res.statusCode().is5xxServerError()) {
              return Mono.error(new RuntimeException("Server Error: can't fetch user"));
          } else {
              return res.createError();
           }
     });
}

In the code above, we retrieve a user and decode responses based on the HTTP status code.

3.2. Emitting a Flux

Furthermore, let’s use exchangeToFlux() to fetch a collection of users:

@GetMapping("/user-exchange-flux")
Flux<User> retrieveUsersWithExchange() {
   return client.get()
     .exchangeToFlux(res -> {
         if (res.statusCode().is2xxSuccessful()) {
             return res.bodyToFlux(User.class);
         } else {
             return Flux.error(new RuntimeException("Error while fetching users"));
         }
    });
}

Here, we use the exchangeToFlux() method to map the response body to a Flux of User objects and return a custom error message if the request fails.

3.3. Retrieving Response Body Directly

Notably, exchangeToMono() or exchangeToFlux() can be used without specifying the response status code:

@GetMapping("/user-exchange")
Flux<User> retrieveAllUserWithExchange(@PathVariable int id) {
    return client.get().exchangeToFlux(res -> res.bodyToFlux(User.class))
      .onErrorResume(Flux::error);
}

Here, we retrieve the user without specifying the status code.

3.4. Altering Response Body

Furthermore, let’s see an example that alters the response body:

@GetMapping("/user/exchange-alter/{id}")
Mono<User> retrieveOneUserWithExchange(@PathVariable int id) {
    return client.get()
      .uri("/{id}", id)
      .exchangeToMono(res -> res.bodyToMono(User.class))
      .map(user -> {
          user.setName(user.getName().toUpperCase());
          user.setId(user.getId() + 100);
          return user;
      });
}

In the code above, after mapping the response body to the POJO class, we alter the response body by adding 100 to the id and capitalizing the name.

Notably, we can also alter the response body with the retrieve() method.

3.5. Extracting Response Headers

Also, we can extract the response headers:

@GetMapping("/user/exchange-header/{id}")
Mono<User> retrieveUsersWithExchangeAndHeader(@PathVariable int id) {
  return client.get()
    .uri("/{id}", id)
    .exchangeToMono(res -> {
        if (res.statusCode().is2xxSuccessful()) {
            logger.info("Status code: " + res.headers().asHttpHeaders());
            logger.info("Content-type" + res.headers().contentType());
            return res.bodyToMono(User.class);
        } else if (res.statusCode().is4xxClientError()) {
            return Mono.error(new RuntimeException("Client Error: can't fetch user"));
        } else if (res.statusCode().is5xxServerError()) {
            return Mono.error(new RuntimeException("Server Error: can't fetch user"));
        } else {
            return res.createError();
        }
    });
}

Here, we log the HTTP header and the content type to the console. Unlike the retrieve() method that needs to return ResponseEntity to access the headers and the response code, exchangeToMono() gives us access directly because it returns ClientResponse.

4. The retrieve() Method

The retrieve() method simplifies the extraction of a response body from an HTTP request. It returns ResponseSpec, which allows us to specify how the response body should be processed without the need to access the complete ClientResponse.

The ClientResponse includes the response code, headers, and body. Therefore, the ResponseSpec includes the response body without the response code and header.

4.1. Emitting a Mono

Here’s an example code that retrieves an HTTP response body:

@GetMapping("/user/{id}")
Mono<User> retrieveOneUser(@PathVariable int id) {
    return client.get()
      .uri("/{id}", id)
      .retrieve()
      .bodyToMono(User.class)
      .onErrorResume(Mono::error);
}

In the code above, we retrieve JSON from the base URL by making an HTTP call to the /users endpoint with a specific id. Then, we mapped the response body to the User object.

4.2. Emitting a Flux

Additionally, let’s see an example that makes a GET request to the /users endpoint:

@GetMapping("/users")
Flux<User> retrieveAllUsers() {
    return client.get()
      .retrieve()
      .bodyToFlux(User.class)
      .onResumeError(Flux::error);
}

Here, the method emits a Flux of User objects when it maps the HTTP response to the POJO class.

4.3. Returning ResponseEntity

In the case where we intend to access the response status and headers with the retrieve() method, we can return ResponseEntity:

@GetMapping("/user-id/{id}")
Mono<ResponseEntity<User>> retrieveOneUserWithResponseEntity(@PathVariable int id) {
    return client.get()
      .uri("/{id}", id)
      .accept(MediaType.APPLICATION_JSON)
      .retrieve()
      .toEntity(User.class)
      .onErrorResume(Mono::error);
}

The response obtained using the toEntity() method contains the HTTP headers, status code, and response body.

4.4. Custom Error With onStatus() Handler

Also, when there is a 400 or 500 HTTP error, it returns the WebClientResponseException error by default. However, we can customize the exception to give a custom error response using the onStatus() handler:

@GetMapping("/user-status/{id}")
Mono<User> retrieveOneUserAndHandleErrorBasedOnStatus(@PathVariable int id) {
    return client.get()
      .uri("/{id}", id)
      .retrieve()
      .onStatus(HttpStatusCode::is4xxClientError, 
        response -> Mono.error(new RuntimeException("Client Error: can't fetch user")))
      .onStatus(HttpStatusCode::is5xxServerError, 
        response -> Mono.error(new RuntimeException("Server Error: can't fetch user")))
      .bodyToMono(User.class);
}

Here, we check the HTTP status code and use the onStatus() handler to define a custom error response.

5. Performance Comparison

Next, let’s write a performance test to compare the execution times of retrieve() and exchangeToFlux() using Java Microbench Harness (JMH). 

First, let’s create a class named RetrieveAndExchangeBenchmarkTest:

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 10, timeUnit = TimeUnit.MICROSECONDS)
@Measurement(iterations = 3, time = 10, timeUnit = TimeUnit.MICROSECONDS)
public class RetrieveAndExchangeBenchmarkTest {
  
    private WebClient client;

    @Setup
    public void setup() {
        this.client = WebClient.create("https://jsonplaceholder.typicode.com/users");
    }
}

Here, we set the benchmark mode to AverageTime, which means it measures the average time for the test to execute. Also, we define the number of iterations and the time to run each iteration.

Next, we create an instance of WebClient and use the @Setup annotation to make it run before each benchmark.

Let’s write a benchmark method that retrieves a collection of users using the retrieve() method:

@Benchmark
Flux<User> retrieveManyUserUsingRetrieveMethod() {
    return client.get()
      .retrieve()
      .bodyToFlux(User.class)
      .onErrorResume(Flux::error);;
}

Finally, let’s define a method that emits a Flux of User objects using the exchangeToFlux() method:

@Benchmark
Flux<User> retrieveManyUserUsingExchangeToFlux() {
    return client.get()
      .exchangeToFlux(res -> res.bodyToFlux(User.class))
      .onErrorResume(Flux::error);
}

Here’s the benchmark result:

Benchmark                             Mode  Cnt   Score    Error  Units
retrieveManyUserUsingExchangeToFlux   avgt   15  ≈ 10⁻⁴            s/op
retrieveManyUserUsingRetrieveMethod   avgt   15  ≈ 10⁻³            s/op

Both methods demonstrate efficient performance. However, the exchangeToFlux() is slightly faster when retrieving a collection of users than the retrieve() method.

6. Key Differences and Similarities

Both retrieve() and exchangeToMono() or exchangeToFlux() can be used to make HTTP requests and extract the HTTP response.

The retrieve() method only allows us to consume the HTTP body and emit a Mono or Flux because it returns ResponseSpec. However, if we want to access the status code and headers, we can use the retrieve() method with ResponseEntity. Also, it allows us to report errors based on the HTTP status code using the onStatus() handler.

Unlike the retrieve() method, the exchangeToMono() and exchnageToFlux() allow us to consume HTTP response and access the headers and response code directly because they return ClientResponse. Furthermore, they provides more control over error handling because we can decode responses based on the HTTP status code.

Notably, in the case where the intention is to consume only the response body, the retrieve() method is advised.

However, if we need more control over the response, the exchangeToMono() or exchangeToFlux() may be the better choice.

7. Conclusion

In this article, we learned how to use the retrieve(), exchangeToMono(), and exchangeToFlux() methods to handle HTTP response and further map the response to a POJO class. Additionally, we compared the performance between the retrieve() and exchangeToFlux() methods.

The retrieve() method is good for scenarios where we only need to consume the response body, and don’t need access to the status code or headers. It simplifies the process by returning ResponseSpec, which provides a straightforward way to handle the response body.

As always, the complete source code for the examples is available over on GitHub.