Calling HTTP APIs with Spring WebClient

Introduction

This tutorial explains WebClient, Spring's non-blocking HTTP client and the client half of Spring WebFlux. We will create a WebClient from Spring Boot's builder, read single objects and lists with retrieve(), and see what non-blocking calls are good for: three calls to slow price services take the time of one when they run together. Then we will handle 4xx and 5xx responses, set a timeout, retry a flaky service with exponential backoff, and look at exchangeToMono() for full control of the response. To have something real to call, the program starts a small price server on a random port first. Every output below comes from running it with Spring Boot 4.1.2.

WebClient, RestClient and RestTemplate

Spring has three HTTP clients today -
  • WebClient (Spring 5+): non-blocking, returns Mono and Flux, streams bodies. The client for WebFlux applications, and for any code that runs many calls concurrently.
  • RestClient (Spring 6.1+): the same fluent API style, but blocking - each call returns the result. The default choice for Spring MVC applications, especially with virtual threads.
  • RestTemplate: the original blocking client, in maintenance; new code should use one of the two above.
In a WebFlux application, WebClient is the only one that does not block the event loop. The class itself is part of spring-webflux, but in Spring Boot 4 the auto-configuration around it has a starter of its own, spring-boot-starter-webclient. With it, Spring Boot provides a preconfigured WebClient.Builder bean - with Boot's JSON codecs, metrics and connection settings - from which an application builds its clients. (The first run of this example, with only the WebFlux starter, failed with "No qualifying bean of type WebClient$Builder".)
pom.xml - dependencies
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webclient</artifactId>
</dependency>

A price server to call

The server stands for remote services: a price lookup that answers after 300 ms, a list of shops, a service that is down twice before it answers, and one that is far too slow -
src/main/java/.../client/Price.java
//Price.java
package com.javabrahman.reactive.client;

public record Price(String shop, String isbn, int amount) {
}
src/main/java/.../client/Shop.java
//Shop.java
package com.javabrahman.reactive.client;

public record Shop(String name) {
}
src/main/java/.../client/PriceServer.java - the remote side
//PriceServer.java
package com.javabrahman.reactive.client;

import java.time.Duration;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@SpringBootApplication
@RestController
public class PriceServer {
  private static final Map<String, Integer> PRICES = Map.of("shopA", 450, "shopB", 399, "shopC", 420);
  private final AtomicInteger flakyCalls = new AtomicInteger();

  @GetMapping("/shops")
  public Flux<Shop> shops() {
    return Flux.fromIterable(PRICES.keySet()).sort().map(Shop::new);
  }

  @GetMapping("/prices/{shop}/{isbn}")
  public Mono<Price> price(@PathVariable String shop, @PathVariable String isbn) {
    Integer amount = PRICES.get(shop);
    if (amount == null) {
      return Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "unknown shop " + shop));
    }
    return Mono.just(new Price(shop, isbn, amount)).delayElement(Duration.ofMillis(300));
  }

  @GetMapping("/flaky")
  public Mono<ResponseEntity<String>> flaky() {
    int call = flakyCalls.incrementAndGet();
    return Mono.just(call <= 2
        ? ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body("busy, call " + call)
        : ResponseEntity.ok("answer on call " + call));
  }

  @GetMapping("/slow")
  public Mono<String> slow() {
    return Mono.just("finally").delayElement(Duration.ofSeconds(3));
  }
}

The client

The client class receives Spring Boot's WebClient.Builder, sets the base URL, and declares one method per call. Each method returns a Mono or Flux: nothing is sent until someone subscribes -
src/main/java/.../client/PriceClient.java
//PriceClient.java
package com.javabrahman.reactive.client;

import java.time.Duration;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;

public class PriceClient {
  private final WebClient web;

  public PriceClient(WebClient.Builder builder, String baseUrl) {
    this.web = builder.baseUrl(baseUrl).defaultHeader("X-Client", "javabrahman-demo").build();
  }

  public Flux<Shop> shops() {
    return web.get().uri("/shops").retrieve().bodyToFlux(Shop.class);
  }

  public Mono<Price> price(String shop, String isbn) {
    return web.get().uri("/prices/{shop}/{isbn}", shop, isbn)
        .retrieve()
        .onStatus(status -> status == HttpStatus.NOT_FOUND,
            response -> response.bodyToMono(String.class).map(body -> new UnknownShopException(shop)))
        .bodyToMono(Price.class);
  }

  public Mono<String> flaky() {
    return web.get().uri("/flaky")
        .retrieve()
        .bodyToMono(String.class)
        .retryWhen(Retry.backoff(3, Duration.ofMillis(100))
            .filter(e -> e instanceof WebClientResponseException.ServiceUnavailable)
            .doBeforeRetry(signal -> System.out.println("   retry " + (signal.totalRetries() + 1)
                + " after a " + ((WebClientResponseException) signal.failure()).getStatusCode())));
  }

  public Mono<String> slow() {
    return web.get().uri("/slow").retrieve().bodyToMono(String.class)
        .timeout(Duration.ofMillis(500));
  }

  // full control: look at the status and headers, then decide how to read the body
  public Mono<String> describe(String path) {
    return web.get().uri(path).exchangeToMono(response ->
        response.bodyToMono(String.class).defaultIfEmpty("")
            .map(body -> response.statusCode().value() + " " + response.headers().contentType().orElse(null)
                + " (" + body.length() + " chars)"));
  }

  public static class UnknownShopException extends RuntimeException {
    UnknownShopException(String shop) {
      super("no such shop: " + shop);
    }
  }
}
  • retrieve() is the usual way: it turns the body into objects with bodyToMono() or bodyToFlux(), and turns any 4xx or 5xx response into a WebClientResponseException - with a subclass per status, such as WebClientResponseException.NotFound.
  • onStatus() replaces that exception with one of your own for selected statuses; here a 404 becomes an UnknownShopException, which the rest of the application can understand without knowing about HTTP.
  • retryWhen() and timeout() are plain Reactor operators of the previous tutorialsClick to read tutorial: Reactive Programming with Project Reactor: Mono and Flux: WebClient adds nothing special for them, because a call is just a Mono.
The program starts the server on a random free port, and runs the calls -
src/main/java/.../client/WebClientDemo.java
//WebClientDemo.java
package com.javabrahman.reactive.client;

import java.util.concurrent.TimeoutException;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.web.server.reactive.context.ReactiveWebServerApplicationContext;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

public class WebClientDemo {

  static long start;

  static String elapsed() {
    return Math.round((System.nanoTime() - start) / 100_000_000.0) / 10.0 + " s";
  }

  public static void main(String[] args) {
    try (var server = (ReactiveWebServerApplicationContext) SpringApplication.run(PriceServer.class, "--server.port=0")) {
      PriceClient client = new PriceClient(server.getBean(WebClient.Builder.class),
          "http://localhost:" + server.getWebServer().getPort());
      client.shops().blockLast();   // warms up the connection, so that the timings below compare fairly

      System.out.println("1. bodyToFlux and bodyToMono:");
      System.out.println("   shops: " + client.shops().map(Shop::name).collectList().block());
      System.out.println("   " + client.price("shopB", "111").block());

      System.out.println("2. three prices, one after the other:");
      start = System.nanoTime();
      int a = client.price("shopA", "111").block().amount();
      int b = client.price("shopB", "111").block().amount();
      int c = client.price("shopC", "111").block().amount();
      System.out.println("   best " + Math.min(a, Math.min(b, c)) + " after " + elapsed());

      System.out.println("3. the same three prices, at the same time with zip:");
      start = System.nanoTime();
      Integer best = Mono.zip(client.price("shopA", "111"), client.price("shopB", "111"), client.price("shopC", "111"))
          .map(t -> Math.min(t.getT1().amount(), Math.min(t.getT2().amount(), t.getT3().amount())))
          .block();
      System.out.println("   best " + best + " after " + elapsed());

      System.out.println("4. a 404, turned into an exception of our own by onStatus:");
      try {
        client.price("shopX", "111").block();
      } catch (PriceClient.UnknownShopException e) {
        System.out.println("   " + e);
      }

      System.out.println("5. a 503 twice, retried with backoff:");
      System.out.println("   " + client.flaky().block());

      System.out.println("6. a call that takes 3 s, with a 500 ms timeout:");
      start = System.nanoTime();
      try {
        client.slow().block();
      } catch (RuntimeException e) {
        System.out.println("   " + (e.getCause() instanceof TimeoutException ? "TimeoutException" : e) + " after " + elapsed());
      }

      System.out.println("7. exchangeToMono - the status and headers first:");
      System.out.println("   /shops          -> " + client.describe("/shops").block());
      System.out.println("   /prices/shopX/1 -> " + client.describe("/prices/shopX/1").block());
    }
  }
}
 OUTPUT of the above code
1. bodyToFlux and bodyToMono:
   shops: [shopA, shopB, shopC]
   Price[shop=shopB, isbn=111, amount=399]
2. three prices, one after the other:
   best 399 after 0.9 s
3. the same three prices, at the same time with zip:
   best 399 after 0.3 s
4. a 404, turned into an exception of our own by onStatus:
   com.javabrahman.reactive.client.PriceClient$UnknownShopException: no such shop: shopX
5. a 503 twice, retried with backoff:
   retry 1 after a 503 SERVICE_UNAVAILABLE
   retry 2 after a 503 SERVICE_UNAVAILABLE
   answer on call 3
6. a call that takes 3 s, with a 500 ms timeout:
   TimeoutException after 0.5 s
7. exchangeToMono - the status and headers first:
   /shops          -> 200 application/json (52 chars)
   /prices/shopX/1 -> 404 application/json (124 chars)
The main method uses block() to print each result; inside a WebFlux application, the Monos would be returned and combined instead, never blocked.

What the run shows

  • Concurrency for free: three calls of 300 ms took about 1 s one after the other, and about 0.3 s with Mono.zip(). No thread pool or CompletableFuture was needed: zip subscribed to the three Monos at once, and the three requests were in flight together, without a thread waiting for any of them. For a list of calls, Flux.fromIterable(shops).flatMap(shop -> client.price(shop, isbn)) does the same, and flatMap(..., 4) limits how many run at once.
  • Errors: the 404 became the application's own exception. Without onStatus(), it would have been a WebClientResponseException.NotFound, which carries the status, headers and body of the response.
  • Retries: the 503 answers were retried after about 100 and 200 ms of backoff (Reactor adds jitter by default), and the third call succeeded. The filter() matters: a 404 or 400 will fail on every attempt, so only "try again" statuses such as 503, 502, 429 and connection errors should be retried.
  • Timeouts: the slow call was cancelled after 0.5 s. A timeout() on the Mono limits the whole call; for connection and response timeouts at the network level, configure the underlying Reactor Netty HttpClient and pass it with clientConnector(new ReactorClientHttpConnector(httpClient)).
  • exchangeToMono() treats every status as a normal response; the code inspects it and decides. It must read or release the body inside the function, which bodyToMono() does.
Three calls of 300 ms each - one after the other with block() they take 0.9 s; with Mono.zip all three requests are in flight at once and the result is ready after 0.3 s

HTTP interface clients

For an API with many endpoints, writing each call by hand gets repetitive. An HTTP interface declares the calls as annotated methods of a Java interface, and Spring generates the implementation on top of a WebClient (or a RestClient) -
An HTTP interface backed by WebClient
public interface PriceApi {

  @GetExchange("/prices/{shop}/{isbn}")
  Mono<Price> price(@PathVariable String shop, @PathVariable String isbn);

  @GetExchange("/shops")
  Flux<String> shops();
}

// creating the client
WebClient web = builder.baseUrl("http://prices.example.com").build();
PriceApi api = HttpServiceProxyFactory.builderFor(WebClientAdapter.create(web)).build()
    .createClient(PriceApi.class);
Spring Framework 7 adds @ImportHttpServices, which registers such interfaces as beans in groups that share a configured client, and Spring Boot 4 configures those groups from properties.

Good practice

  • Build from Spring Boot's WebClient.Builder, so that codecs, metrics and tracing are configured. Build one client per remote service and reuse it: a WebClient is thread-safe, and its connection pool is shared.
  • Always set timeouts: a remote service that never answers otherwise keeps the request open indefinitely.
  • Retry only idempotent calls, only on transient failures, and with backoff.
  • Never block() in a WebFlux application; on an event-loop thread it throws an IllegalStateException. In a Spring MVC application that only makes a few calls, RestClient is simpler than a blocked WebClient.
  • Large or endless bodies: bodyToFlux() streams elements as they arrive, for example from an NDJSON or Server-Sent Events endpoint, without holding the whole body in memory. bodyToMono() is limited to 256 KB by default (spring.codec.max-in-memory-size).

Summary

In this tutorial we called a price server with WebClient, built from Spring Boot's WebClient.Builder with a base URL. retrieve() with bodyToMono() and bodyToFlux() read objects and lists; three slow calls took about 1 s in sequence and 0.3 s with Mono.zip(). onStatus() mapped a 404 to an application exception, retryWhen(Retry.backoff()) got past two 503 answers, timeout() stopped a call after 0.5 s, and exchangeToMono() gave access to the status and headers. HTTP interfaces declare such calls as annotated Java methods. The next tutorial makes the database non-blocking too: Spring Data R2DBCClick to read tutorial: Reactive Database Access with Spring Data R2DBC.