Table of Contents#
- Prerequisites
- Understanding WebClient and Blocking Calls
- Default Exception Behavior in WebClient
- Basic Exception Handling with Try/Catch
- Handling 4xx/5xx Errors Specifically
- Advanced: Customizing Errors with
onStatus() - Extracting Error Response Bodies
- Best Practices for Exception Handling
- Conclusion
- 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 ofWebClientResponseException). - 5xx errors (e.g., 500 Internal Server Error) throw
HttpServerErrorException(also a subclass ofWebClientResponseException).
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:
WebClientResponseExceptionprovides details like status code (e.getStatusCode()), status text (e.getStatusText()), and response headers (e.getHeaders()).- Avoid catching
Exceptionas 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:
HttpClientErrorExceptionis thrown exclusively for 4xx status codes.HttpServerErrorExceptionis 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 defaultObjectMapperto parse the response body into yourApiErrorrecord.e.getResponseBodyAsString()returns the raw error body as a string (useful for debugging).
Best Practices for Exception Handling#
-
Catch Specific Exceptions: Avoid
catch (Exception e)—it masks bugs. CatchWebClientResponseException,HttpClientErrorException,HttpServerErrorException, or custom exceptions explicitly. -
Log Context: Always log the status code, URI, and error details (e.g.,
e.getStatusCode(),e.getResponseBodyAsString()) for debugging. -
Rethrow Custom Exceptions: Convert low-level WebClient exceptions into application-specific exceptions (e.g.,
UserNotFoundException,ServiceUnavailableException) to keep business logic clean. -
Avoid Silent Failures: Never suppress exceptions without logging or handling them (e.g., empty
catchblocks). -
Use
onStatus()for Reusability: Centralize error logic withonStatus()to avoid duplicatingtry/catchblocks across requests. -
Handle Network Errors: Catch
IOException(orWebClientRequestException) 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.