cyberangles blog

Spring WebClient: How to Catch Exceptions with Try/Catch for Blocking Synchronous Requests (Handling 4xx/5xx Errors)

In modern Spring applications, WebClient has emerged as the recommended alternative to the legacy RestTemplate for making HTTP requests. Built on Spring WebFlux, WebClient is natively reactive and non-blocking, but it also supports blocking synchronous requests via the block() method—making it versatile for both reactive and traditional applications.

However, when using WebClient synchronously, unhandled exceptions (especially for 4xx/5xx HTTP errors) can crash your application or lead to unexpected behavior. This blog will guide you through handling exceptions in blocking WebClient requests using try/catch, with a focus on gracefully managing 4xx (client errors) and 5xx (server errors). By the end, you’ll know how to catch, inspect, and respond to HTTP errors effectively.

2025-11

Table of Contents#

  1. Prerequisites
  2. Understanding WebClient and Blocking Calls
  3. Default Exception Behavior in WebClient
  4. Basic Exception Handling with Try/Catch
  5. Handling 4xx/5xx Errors Specifically
  6. Advanced: Customizing Errors with onStatus()
  7. Extracting Error Response Bodies
  8. Best Practices for Exception Handling
  9. Conclusion
  10. References

Prerequisites#

To follow along, ensure you have:

  • A basic understanding of Spring Boot.
  • Spring Boot 2.7+ or 3.x (WebClient is included in Spring WebFlux).
  • Java 11 or higher.
  • Maven/Gradle dependency for spring-boot-starter-webflux (to use WebClient).

Maven Dependency (add to pom.xml):

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

Understanding WebClient and Blocking Calls#

WebClient is designed for reactive, non-blocking I/O, but it can be forced to behave synchronously using the block() method. This is useful for migrating legacy RestTemplate code or for applications that don’t need full reactive capabilities.

A typical blocking WebClient request looks like this:

import org.springframework.web.reactive.function.client.WebClient;  
 
public class UserClient {  
    private final WebClient webClient;  
 
    public UserClient() {  
        this.webClient = WebClient.create("https://api.example.com"); // Base URL  
    }  
 
    public User getUserById(String userId) {  
        // Blocking call: waits for the response  
        return webClient.get()  
                .uri("/users/{id}", userId)  
                .retrieve() // Fetches response; throws on 4xx/5xx by default  
                .bodyToMono(User.class) // Maps response body to User object (reactive Mono)  
                .block(); // Blocks until the Mono completes  
    }  
}  

Here, block() halts execution until the reactive pipeline (Mono<User>) emits a result or an error.

Default Exception Behavior in WebClient#

By default, WebClient’s retrieve() method throws exceptions for 4xx (client errors) and 5xx (server errors). Specifically:

  • 4xx errors (e.g., 404 Not Found) throw HttpClientErrorException (a subclass of WebClientResponseException).
  • 5xx errors (e.g., 500 Internal Server Error) throw HttpServerErrorException (also a subclass of WebClientResponseException).

If unhandled, these exceptions propagate up the call stack, potentially crashing the application.

Example: Unhandled 4xx Error#

Suppose we call getUserById("invalid-id"), and the API returns 404 Not Found. Without exception handling:

public static void main(String[] args) {  
    UserClient client = new UserClient();  
    User user = client.getUserById("invalid-id"); // Throws HttpClientErrorException  
}  

Output:

Exception in thread "main" org.springframework.web.reactive.function.client.HttpClientErrorException$NotFound: 404 Not Found  
    at org.springframework.web.reactive.function.client.WebClientResponseException.create(WebClientResponseException.java:212)  
    ...  

Basic Exception Handling with Try/Catch#

To prevent unhandled exceptions, wrap the block() call in a try/catch block. Catch WebClientResponseException (the parent class for 4xx/5xx errors) to handle HTTP-related issues, and other exceptions (e.g., IOException for network failures) for non-HTTP errors.

Example: Basic Try/Catch#

public User getUserById(String userId) {  
    try {  
        return webClient.get()  
                .uri("/users/{id}", userId)  
                .retrieve()  
                .bodyToMono(User.class)  
                .block();  
    } catch (WebClientResponseException e) {  
        // Handle HTTP errors (4xx/5xx)  
        System.err.printf("HTTP Error: %s %s%n", e.getStatusCode(), e.getStatusText());  
        return null; // Or throw a custom exception  
    } catch (Exception e) {  
        // Handle non-HTTP errors (e.g., network timeout, DNS failure)  
        System.err.printf("Non-HTTP Error: %s%n", e.getMessage());  
        return null;  
    }  
}  

Key Notes:

  • WebClientResponseException provides details like status code (e.getStatusCode()), status text (e.getStatusText()), and response headers (e.getHeaders()).
  • Avoid catching Exception as a blanket fallback (see Best Practices).

Handling 4xx/5xx Errors Specifically#

For granular control, catch HttpClientErrorException (4xx) and HttpServerErrorException (5xx) separately. This lets you handle client-side errors (e.g., invalid input) differently from server-side errors (e.g., service outage).

Example: Targeted 4xx/5xx Handling#

public User getUserById(String userId) {  
    try {  
        return webClient.get()  
                .uri("/users/{id}", userId)  
                .retrieve()  
                .bodyToMono(User.class)  
                .block();  
    } catch (HttpClientErrorException e) { // 4xx errors  
        if (e.getStatusCode() == HttpStatus.NOT_FOUND) {  
            System.err.println("User not found (404)");  
            return new User(); // Return default user  
        } else if (e.getStatusCode() == HttpStatus.BAD_REQUEST) {  
            System.err.println("Invalid user ID (400)");  
            throw new InvalidInputException("User ID is invalid", e); // Rethrow as custom exception  
        }  
    } catch (HttpServerErrorException e) { // 5xx errors  
        System.err.printf("Server error: %s (status: %d)%n", e.getStatusText(), e.getRawStatusCode());  
        throw new ServiceUnavailableException("User service is down", e); // Rethrow as custom exception  
    } catch (IOException e) { // Network issues (e.g., timeout)  
        System.err.println("Network error: " + e.getMessage());  
        throw new RetryableException("Failed to connect. Please retry.", e);  
    }  
    return null;  
}  

Why This Works:

  • HttpClientErrorException is thrown exclusively for 4xx status codes.
  • HttpServerErrorException is thrown exclusively for 5xx status codes.
  • You can check e.getStatusCode() for specific statuses (e.g., 404 NOT_FOUND, 400 BAD_REQUEST).

Advanced: Customizing Errors with onStatus()#

WebClient’s onStatus() method lets you customize exceptions before the block() call. Use it to map specific status codes to custom exceptions, making try/catch blocks cleaner.

Example: Using onStatus() to Throw Custom Exceptions#

public User getUserById(String userId) {  
    try {  
        return webClient.get()  
                .uri("/users/{id}", userId)  
                .retrieve()  
                // Customize error handling for 4xx/5xx BEFORE blocking  
                .onStatus(  
                    HttpStatus::is4xxClientError, // Trigger for 4xx  
                    response -> Mono.error(new CustomClientException(  
                        "Client error: " + response.statusCode()  
                    ))  
                )  
                .onStatus(  
                    HttpStatus::is5xxServerError, // Trigger for 5xx  
                    response -> Mono.error(new CustomServerException(  
                        "Server error: " + response.statusCode()  
                    ))  
                )  
                .bodyToMono(User.class)  
                .block();  
    } catch (CustomClientException e) { // Catch custom 4xx exception  
        System.err.println("Custom Client Error: " + e.getMessage());  
    } catch (CustomServerException e) { // Catch custom 5xx exception  
        System.err.println("Custom Server Error: " + e.getMessage());  
    }  
    return null;  
}  
 
// Custom exception classes  
class CustomClientException extends RuntimeException {  
    public CustomClientException(String message) { super(message); }  
}  
 
class CustomServerException extends RuntimeException {  
    public CustomServerException(String message) { super(message); }  
}  

Benefits:

  • Decouples error logic from the blocking call.
  • Enforces consistent error handling across requests.

Extracting Error Response Bodies#

APIs often return structured error bodies (e.g., JSON) for 4xx/5xx responses (e.g., {"error": "Not Found", "details": "User 123 does not exist"}). Use WebClientResponseException to extract these bodies.

Example: Parsing Error Response Bodies#

Suppose the error response body is:

{  
  "error": "Not Found",  
  "message": "User with ID 'invalid-id' not found",  
  "timestamp": "2024-01-01T12:00:00Z"  
}  

Define a POJO to map the error:

record ApiError(String error, String message, String timestamp) {}  

Now extract and parse it in the catch block:

catch (WebClientResponseException e) {  
    try {  
        // Parse error body into ApiError  
        ApiError error = e.getResponseBodyAs(ApiError.class);  
        System.err.printf("API Error: %s - %s%n", error.error(), error.message());  
    } catch (JsonProcessingException ex) {  
        // Fallback if parsing fails  
        System.err.println("Failed to parse error body: " + e.getResponseBodyAsString());  
    }  
}  

How It Works:

  • e.getResponseBodyAs(ApiError.class) uses Spring’s default ObjectMapper to parse the response body into your ApiError record.
  • e.getResponseBodyAsString() returns the raw error body as a string (useful for debugging).

Best Practices for Exception Handling#

  1. Catch Specific Exceptions: Avoid catch (Exception e)—it masks bugs. Catch WebClientResponseException, HttpClientErrorException, HttpServerErrorException, or custom exceptions explicitly.

  2. Log Context: Always log the status code, URI, and error details (e.g., e.getStatusCode(), e.getResponseBodyAsString()) for debugging.

  3. Rethrow Custom Exceptions: Convert low-level WebClient exceptions into application-specific exceptions (e.g., UserNotFoundException, ServiceUnavailableException) to keep business logic clean.

  4. Avoid Silent Failures: Never suppress exceptions without logging or handling them (e.g., empty catch blocks).

  5. Use onStatus() for Reusability: Centralize error logic with onStatus() to avoid duplicating try/catch blocks across requests.

  6. Handle Network Errors: Catch IOException (or WebClientRequestException) for network issues (timeouts, DNS failures) and consider retry logic.

Conclusion#

WebClient simplifies making HTTP requests in Spring, and with try/catch, you can gracefully handle 4xx/5xx errors in blocking scenarios. By combining try/catch with onStatus() and custom exceptions, you can build robust error-handling workflows that provide clarity and resilience.

Remember to prioritize specific exception catches, log context-rich details, and map errors to domain-specific exceptions for maintainable code.

References#