Exception Handling

About

When making an HTTP call using WebClient, many things can go wrong:

  • The target server is down

  • It returns a non-2xx status code (e.g. 404, 500)

  • The response body is malformed or deserialization fails

  • Network timeouts or DNS failures

We need to handle these failures gracefully and centrally, either by throwing meaningful exceptions or by performing fallback logic.

Types of Exceptions We Might Encounter

When making HTTP calls with WebClient, our application can run into a wide range of issues, both client-side and server-side. Understanding these exception types is essential for building robust and fault-tolerant systems. They generally fall into three broad categories:

1. Client-Side I/O Exceptions (Request-Time Errors)

These occur when our application is unable to successfully send the HTTP request.

Exception

What It Means

When It Happens

WebClientRequestException

A low-level issue occurred while sending the request (network issue, DNS resolution failure, timeout, connection refused)

The server is down, the host is unreachable, or the request couldn’t be sent

ConnectTimeoutException (wrapped inside WebClientRequestException)

The client could not establish a connection within the configured time

Target service took too long to respond to the connection attempt

ReadTimeoutException (wrapped inside WebClientRequestException)

Connection was established, but the server didn’t send a response in time

Slow backend services or misconfigured timeouts

UnknownHostException

Hostname could not be resolved

DNS failure or incorrect domain name

These are typically retriable (e.g., with retry policies or circuit breakers).

Host Not Found / DNS Failure

This occurs when the domain or hostname used in the URI cannot be resolved by the DNS resolver. It typically means the domain doesn't exist, there's a typo in the hostname, or DNS is misconfigured.

This simulates a situation where the hostname doesn't exist or cannot be resolved.

import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientRequestException;
import reactor.core.publisher.Mono;

public class DnsFailureExample {
    public static void main(String[] args) {
        WebClient webClient = WebClient.create();

        webClient.get()
                .uri("http://nonexistent-host-xyz123.internal/api/test")
                .retrieve()
                .bodyToMono(String.class)
                .doOnError(WebClientRequestException.class, ex -> {
                    System.out.println("DNS resolution failed: " + ex.getMessage());
                })
                .onErrorResume(WebClientRequestException.class, ex -> Mono.empty())
                .block();
    }
}

Connection Refused (Server Down or Port Closed)

The client resolves the host and attempts a connection, but no service is listening on the given port. This is common when:

  • The server crashed or hasn’t started.

  • The port is misconfigured.

  • The service has not bound properly on the expected interface.

Simulate when a service is not listening on the target port.

import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientRequestException;
import reactor.core.publisher.Mono;

public class ConnectionRefusedExample {
    public static void main(String[] args) {
        WebClient webClient = WebClient.create();

        webClient.get()
                .uri("http://localhost:9999/api/users") // Assuming port 9999 is closed
                .retrieve()
                .bodyToMono(String.class)
                .doOnError(WebClientRequestException.class, ex -> {
                    System.out.println("Connection refused: " + ex.getMessage());
                })
                .onErrorResume(WebClientRequestException.class, ex -> Mono.just("Fallback response"))
                .block();
    }
}

Connect Timeout

This occurs when the TCP handshake cannot be completed within a defined period. It is not the same as server slowness—it happens before the request is even sent.

We can simulate this by setting a very short timeout and targeting a delayed service.

import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.tcp.TcpClient;
import io.netty.channel.ChannelOption;

import java.time.Duration;

public class ConnectTimeoutExample {
    public static void main(String[] args) {
        TcpClient tcpClient = TcpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 500); // Very short timeout

        WebClient webClient = WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient)))
                .build();

        webClient.get()
                .uri("http://10.255.255.1:8080") // unroutable IP to force timeout
                .retrieve()
                .bodyToMono(String.class)
                .timeout(Duration.ofSeconds(2))
                .doOnError(Exception.class, ex -> System.out.println("Connection timeout: " + ex.getMessage()))
                .onErrorResume(ex -> Mono.just("Timeout fallback"))
                .block();
    }
}

Read Timeout

This happens after the connection is established and the request is sent, but the server takes too long to respond with even a single byte.

We simulate this by calling a server that deliberately delays the response.

import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;

import java.time.Duration;

public class ReadTimeoutExample {
    public static void main(String[] args) {
        HttpClient httpClient = HttpClient.create(ConnectionProvider.newConnection())
                .responseTimeout(Duration.ofSeconds(1)); // Read timeout

        WebClient webClient = WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();

        webClient.get()
                .uri("http://httpstat.us/200?sleep=5000") // 5-second sleep
                .retrieve()
                .bodyToMono(String.class)
                .doOnError(Exception.class, ex -> System.out.println("Read timeout: " + ex.getMessage()))
                .onErrorResume(ex -> Mono.just("Read timeout fallback"))
                .block();
    }
}

2. Server Response Exceptions (Non-2xx Responses)

These occur when the request was successfully sent, but the server responded with an error status code (e.g. 4xx, 5xx).

Exception

What It Means

When It Happens

WebClientResponseException

The server returned a non-2xx HTTP status code

API returned 400, 404, 500, etc.

Subtypes:

WebClientResponseException.BadRequest

WebClientResponseException.NotFound

WebClientResponseException.InternalServerError

These are specific subclasses for common HTTP errors

Allows targeted handling (e.g., only for 404 or 500 errors)

UnknownHttpStatusCodeException

Server returned a status code not defined in the HttpStatus enum

Happens rarely with non-standard HTTP status codes (e.g. 600)

These exceptions carry full HTTP response data (status, headers, body) and are useful for error decoding or fallback logic.

Common Status Codes that Trigger Exceptions

HTTP Status

Scenario

Typical Meaning

400

Bad Request

Client sent malformed request

401 / 403

Unauthorized / Forbidden

Authentication/Authorization failure

404

Not Found

Resource missing

409

Conflict

Duplicate or state conflict

422

Unprocessable Entity

Validation failure

500

Internal Server Error

Server-side exception

503

Service Unavailable

Server overloaded or under maintenance

Handling 4xx and 5xx

Handling 404 Not Found

A 404 Not Found status indicates the client made a valid request, but the server could not locate the resource. In a microservices context, this might mean:

  • The client is querying with a non-existent ID.

  • The downstream service has deleted or never created the requested entity.

  • The endpoint path is incorrect or deprecated.

This type of error is common in read-heavy APIs like catalog, account, or order lookup services.

WebClient webClient = WebClient.create();

webClient.get()
        .uri("http://product-service/api/products/9999") // Non-existent ID
        .retrieve()
        .onStatus(status -> status.value() == 404,
                  clientResponse -> Mono.error(new RuntimeException("Product not found")))
        .bodyToMono(String.class)
        .doOnError(ex -> System.out.println("Error occurred: " + ex.getMessage()))
        .onErrorResume(ex -> Mono.just("Fallback response"))
        .block();

Handling 400 Bad Request

A 400 Bad Request typically happens when the client sends malformed data. This could involve

  • Missing required fields.

  • Invalid JSON format.

  • Violations of schema-level validation (like invalid enum, incorrect types).

It's common in POST/PUT APIs like form submissions, resource creation, or updates.

webClient.post()
        .uri("http://order-service/api/orders")
        .bodyValue(new OrderRequest()) // Assume missing required fields
        .retrieve()
        .onStatus(status -> status.value() == 400,
                  response -> response.bodyToMono(String.class)
                                      .map(body -> new IllegalArgumentException("Bad request: " + body)))
        .bodyToMono(String.class)
        .onErrorResume(ex -> Mono.just("Client-side input error"))
        .block();

Catch All for 5xx Errors

5xx errors represent server-side faults — failures that are not the client’s responsibility. This could be due to:

  • Null pointer exception in the downstream service.

  • Database outages or timeouts.

  • Resource exhaustion (e.g., memory, thread pool).

  • Unhandled exceptions.

These are transient errors that are often recoverable.

webClient.get()
        .uri("http://inventory-service/api/status")
        .retrieve()
        .onStatus(status -> status.is5xxServerError(),
                  clientResponse -> Mono.error(new RuntimeException("Server error: Try later")))
        .bodyToMono(String.class)
        .onErrorResume(ex -> Mono.just("System temporarily unavailable"))
        .block();

Handling Specific Error Code with Custom Logging

webClient.get()
        .uri("http://payment-service/api/payments/123")
        .retrieve()
        .onStatus(status -> status.value() == 409,
                  response -> {
                      System.out.println("Conflict detected: Possibly a duplicate payment.");
                      return Mono.error(new IllegalStateException("Duplicate operation"));
                  })
        .bodyToMono(String.class)
        .onErrorResume(ex -> Mono.just("Conflict fallback"))
        .block();

Catch all server response exceptions

Sometimes we want to handle any server error generically (4xx or 5xx) to avoid writing multiple .onStatus() handlers for each code. We can capture and inspect the exception using WebClientResponseException.

Using WebClientResponseException in doOnError()

If we want to globally catch all server response exceptions:

webClient.get()
        .uri("http://user-service/api/users/abc")
        .retrieve()
        .bodyToMono(String.class)
        .doOnError(WebClientResponseException.class, ex -> {
            System.out.println("Received " + ex.getStatusCode() + ": " + ex.getResponseBodyAsString());
        })
        .onErrorResume(WebClientResponseException.class, ex -> Mono.just("Graceful fallback"))
        .block();

3. Deserialization and Response Mapping Errors

Deserialization and response mapping errors occur when the HTTP response body cannot be properly converted into the desired Java object. These issues often arise in real-world enterprise systems where:

  • The remote service returns unexpected JSON structure

  • The target DTO has mismatched fields or missing annotations

  • A non-JSON body is returned (e.g., HTML error page)

  • Empty or null response is mapped to a non-nullable field

Exception

What It Means

When It Happens

DecodingException or DecoderException (wrapped)

The response body could not be parsed into the expected object

Mismatched fields, corrupted JSON, wrong content-type

JsonProcessingException / MismatchedInputException

Jackson failed to bind JSON to a Java class

API response has missing/extra fields or type mismatch

IllegalStateException

Attempted to read body multiple times, or from an empty stream

Usually a mistake in chaining reactive operators

These errors highlight the importance of proper type matching and using DTOs that reflect the real response structure.

Issues

Mismatched Fields in Response Body

Assume the server returns:

{
  "id": 1,
  "name": "Alice",
  "status": "ACTIVE"
}

But our Java class is:

public class UserDTO {
    private Long id;
    private String fullName; // mismatch: "name" ≠ "fullName"
}
webClient.get()
    .uri("http://user-service/api/users/1")
    .retrieve()
    .bodyToMono(UserDTO.class)
    .doOnError(Exception.class, ex -> System.out.println("Mapping failed: " + ex.getMessage()))
    .onErrorResume(ex -> Mono.empty())
    .block();

The error will likely be a JsonMappingException due to field mismatch.

Response Is Not JSON

Suppose the server responds with an HTML error page instead of JSON, and we try to deserialize it.

webClient.get()
    .uri("http://example.com/api/data")
    .retrieve()
    .bodyToMono(MyData.class)
    .doOnError(Exception.class, ex -> System.out.println("Deserialization failed: " + ex.getMessage()))
    .onErrorResume(ex -> Mono.just(new MyData("default")))
    .block();

Common in misconfigured reverse proxies or generic 404 pages returned from load balancers.

Empty Response for a Non-Void Mapping

webClient.get()
    .uri("http://remote/api/config")
    .retrieve()
    .bodyToMono(Config.class) // But response is completely empty
    .block();

We may get DecodingException: JSON decoding error or a No content to map to object due to end of input.

How to Handle It Gracefully ?

Use .onStatus() for HTTP Errors

This separates HTTP error status from deserialization logic.

.retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
    response -> response.bodyToMono(String.class)
        .flatMap(body -> Mono.error(new RuntimeException("API error: " + body))))

Catch Deserialization Exceptions Specifically

webClient.get()
    .uri("/api/user/1")
    .retrieve()
    .bodyToMono(UserDTO.class)
    .doOnError(JsonMappingException.class, ex -> {
        System.out.println("Field mismatch or bad format: " + ex.getMessage());
    })
    .onErrorResume(JsonMappingException.class, ex -> Mono.just(new UserDTO()))
    .block();

Use .bodyToMono(String.class) and Deserialize Manually

If the structure is inconsistent, fetch raw JSON and map it manually.

webClient.get()
    .uri("/api/flexible")
    .retrieve()
    .bodyToMono(String.class)
    .map(json -> {
        ObjectMapper mapper = new ObjectMapper();
        try {
            return mapper.readValue(json, MyFlexibleDto.class);
        } catch (Exception e) {
            throw new RuntimeException("Manual parse failed", e);
        }
    })
    .block();

Last updated