Course topics

By WebNest Studio

Spring Boot Tutorial

Calling Services Reactively with WebClient

WebClient is Spring's non-blocking HTTP client. It is the natural choice inside WebFlux applications, and it is also useful in any application that needs to call many services concurrently or consume streaming responses (Server-Sent Events, streaming AI APIs, large downloads) efficiently.

This lesson covers configuring WebClient in Spring Boot 4, making requests and handling responses, error handling, timeouts and retries, calling several services in parallel, consuming streams, adding filters for authentication and logging, and testing with MockWebServer.

Setup and the Builder

In a WebFlux application, spring-boot-starter-webflux brings WebClient. In a Spring MVC application, add spring-boot-starter-webclient (new in Spring Boot 4). Inject the auto-configured WebClient.Builder, which carries the application's codecs and WebClientCustomizer beans, then set a base URL and default headers. Timeouts can be set globally with spring.http.clients.* or per client through the connector.

Requests and Responses

The API mirrors RestClient: get()/post() → uri(...) → retrieve() → bodyToMono(Type.class), bodyToFlux(Type.class) or toEntity(...). Nothing is sent until the returned publisher is subscribed. onStatus maps error statuses to exceptions; by default 4xx and 5xx produce WebClientResponseException.

Resilience

Combine Reactor operators for robust calls: timeout(Duration) caps response time; retryWhen(Retry.backoff(3, Duration.ofMillis(200)).filter(...)) retries transient failures only (5xx, connection errors) with exponential back-off and jitter; onErrorResume provides fallbacks. Never retry non-idempotent requests without an idempotency key.

Using WebClient from Spring MVC

In a blocking application you can call .block() at the edge, but if you only need synchronous calls, RestClient is simpler. WebClient pays off in MVC when you need to fan out to many services concurrently and then combine the results, or to consume streams.

Examples

Configuring a client and making requests

Java
@Configuration
public class ClientsConfig {

    @Bean
    WebClient catalogWebClient(WebClient.Builder builder,
                               @Value("${catalog.base-url}") String baseUrl) {
        return builder
            .baseUrl(baseUrl)
            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            .build();
    }
}

@Service
public class CatalogReactiveClient {

    private final WebClient client;

    public CatalogReactiveClient(WebClient catalogWebClient) {
        this.client = catalogWebClient;
    }

    public Mono<Product> product(long id) {
        return client.get().uri("/api/products/{id}", id)
            .retrieve()
            .onStatus(s -> s.value() == 404, r -> Mono.error(new ProductNotFoundException(id)))
            .bodyToMono(Product.class);
    }

    public Flux<Product> byCategory(String category) {
        return client.get()
            .uri(u -> u.path("/api/products").queryParam("category", category).build())
            .retrieve()
            .bodyToFlux(Product.class);
    }

    public Mono<Product> create(NewProduct p) {
        return client.post().uri("/api/products")
            .bodyValue(p)
            .retrieve()
            .bodyToMono(Product.class);
    }
}
Output
product(5)          -> Mono emits Product[id=5, name=Hoodie, price=1299.00]
product(999)        -> Mono errors with ProductNotFoundException
byCategory("books") -> Flux emits 12 products as they are decoded

Timeouts, retries with back-off, and fallbacks

Java
public Mono<Rates> rates(String base) {
    return client.get().uri("/rates/{base}", base)
        .retrieve()
        .bodyToMono(Rates.class)
        .timeout(Duration.ofSeconds(2))
        .retryWhen(Retry.backoff(3, Duration.ofMillis(200))
            .jitter(0.5)
            .filter(ex -> ex instanceof WebClientResponseException.ServiceUnavailable
                       || ex instanceof WebClientRequestException
                       || ex instanceof TimeoutException))
        .onErrorResume(ex -> ratesCache.lastKnown(base));   // stale but useful fallback
}
Output
attempt 1 -> 503
attempt 2 (after ~200 ms) -> 503
attempt 3 (after ~400 ms) -> 200 OK
(if all attempts fail -> last known rates returned from cache)

Parallel fan-out from a Spring MVC controller

Java
@GetMapping("/api/product-page/{id}")
public ProductPage page(@PathVariable long id) {
    Mono<Product> product = catalog.product(id);
    Mono<List<Review>> reviews = reviewsClient.forProduct(id).collectList();
    Mono<Stock> stock = inventoryClient.stock(id);

    return Mono.zip(product, reviews, stock)
        .map(t -> new ProductPage(t.getT1(), t.getT2(), t.getT3()))
        .block(Duration.ofSeconds(3));     // blocking once, at the edge of an MVC app
}
Output
catalog: 180 ms, reviews: 240 ms, inventory: 150 ms
total ≈ 250 ms (in parallel) instead of ≈ 570 ms (sequential)

Consuming a Server-Sent Events stream and testing with MockWebServer

Java
public Flux<PriceTick> livePrices(String symbol) {
    return client.get().uri("/api/prices/{s}/stream", symbol)
        .accept(MediaType.TEXT_EVENT_STREAM)
        .retrieve()
        .bodyToFlux(PriceTick.class);
}

// Test with OkHttp MockWebServer
class CatalogReactiveClientTest {

    MockWebServer server = new MockWebServer();

    @Test
    void readsProduct() throws IOException {
        server.enqueue(new MockResponse()
            .setHeader("Content-Type", "application/json")
            .setBody("{\"id\":5,\"name\":\"Hoodie\",\"price\":1299.00}"));
        server.start();

        WebClient client = WebClient.create(server.url("/").toString());
        StepVerifier.create(new CatalogReactiveClient(client).product(5))
            .expectNextMatches(p -> p.name().equals("Hoodie"))
            .verifyComplete();
    }
}
Output
livePrices("WEBNEST") -> emits a PriceTick every second until cancelled
CatalogReactiveClientTest > readsProduct() PASSED

Common Mistakes

  • Building a WebClient request and never subscribing (or returning) it, so no HTTP call is made.
  • Calling block() inside a WebFlux handler, which throws or freezes the event loop.
  • Retrying every error, including 400 and non-idempotent POSTs.
  • Creating WebClient.create() per request instead of reusing configured instances.
  • Using WebClient with block() everywhere in an MVC app where RestClient would be simpler.

Key Points to Remember

  • WebClient is the non-blocking HTTP client; in Boot 4 MVC apps add spring-boot-starter-webclient.
  • retrieve() + bodyToMono/bodyToFlux; nothing happens until subscription.
  • Use timeout, retryWhen(Retry.backoff(...)) with filters, and onErrorResume fallbacks.
  • Mono.zip fans out calls in parallel; bodyToFlux consumes streams such as SSE.
  • Test with MockWebServer and StepVerifier.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.