Global Error Handling for Spring WebFlux Functional REST APIs
Centralized exception handling in reactive REST services avoids duplicating try/catch logic across handlers, produces consistent error payloads, and centralizes error code management.
- Single place to map exceptions to HTTP status codes and payloads
- Consistent JSON shape for all failures
- Cleaner handler functions with business logic only
Project setup
- Runtime: JDK 8+, Maven 3+
- Stack: Spring Boot 2.x, spring-boot-starter-webflux
- No application.properties required for this example
Functional routing
Map a GET endpoint to a handler using RouterFunction.
// ApiRoutes.java
package com.example.webflux.error;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.RequestPredicate;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.function.server.RequestPredicates;
@Configuration
public class ApiRoutes {
@Bean
public RouterFunction<ServerResponse> greetingRoutes(GreetingHandler handler) {
RequestPredicate predicate = RequestPredicates
.GET("/greet")
.and(RequestPredicates.accept(MediaType.TEXT_PLAIN));
return RouterFunctions.route(predicate, handler::greet);
}
}
RouterFunction routes any GET /greet request with text/plain to the greet handler method.
Handler
Validate input and, if invalid, throw a domain-specific exception that the global handler will translate into an error response.
// GreetingHandler.java
package com.example.webflux.error;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
@Component
public class GreetingHandler {
public Mono<ServerResponse> greet(ServerRequest req) {
return Mono.defer(() -> Mono.just(
req.queryParam("name")
.filter(s -> !s.trim().isEmpty())
.orElseThrow(() -> new ApiException(HttpStatus.BAD_REQUEST,
"query parameter 'name' is required"))
)).flatMap(name -> ServerResponse.ok()
.contentType(MediaType.TEXT_PLAIN)
.bodyValue("Hi, " + name));
}
}
- Mono is used because each request returns a single response.
- Throwing ApiException lets downstream global handlers produce a unified JSON error.
Domain excepttion
Use ResponseStatusException to carry HTTP status and reason across the reactive pipeline.
// ApiException.java
package com.example.webflux.error;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class ApiException extends ResponseStatusException {
public ApiException(HttpStatus status, String reason) {
super(status, reason);
}
public ApiException(HttpStatus status, String reason, Throwable cause) {
super(status, reason, cause);
}
}
Error attributes
Customize error attributes to shape the JSON payload returned to clients.
// ApiErrorAttributes.java
package com.example.webflux.error;
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class ApiErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
Map<String, Object> base = super.getErrorAttributes(request, includeStackTrace);
Throwable error = getError(request);
Map<String, Object> attrs = new LinkedHashMap<>();
if (error instanceof ApiException) {
ApiException ex = (ApiException) error;
attrs.put("exception", ex.getClass().getSimpleName());
attrs.put("message", ex.getReason());
attrs.put("status", ex.getStatus().value());
attrs.put("error", ex.getStatus().getReasonPhrase());
} else {
attrs.put("exception", error != null ? error.getClass().getSimpleName() : "UnknownException");
attrs.put("message", "Unexpected error. See logs for details.");
attrs.put("status", 500);
attrs.put("error", "Internal Server Error");
}
// Optional: include path and timestamp from the base map
attrs.put("path", base.get("path"));
attrs.put("timestamp", base.get("timestamp"));
return attrs;
}
}
Global error WebExceptionHandler
Route all exceptions to a single renderer that converts ApiErrorAttributes into a resopnse.
// ApiErrorWebExceptionHandler.java
package com.example.webflux.error;
import org.springframework.boot.autoconfigure.web.ResourceProperties;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.*;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.http.HttpStatus;
import org.springframework.http.codec.ServerCodecConfigurer;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Map;
@Component
@Order(-2)
public class ApiErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {
public ApiErrorWebExceptionHandler(ApiErrorAttributes errorAttributes,
ApplicationContext applicationContext,
ServerCodecConfigurer codecConfigurer) {
super(errorAttributes, new ResourceProperties(), applicationContext);
super.setMessageReaders(codecConfigurer.getReaders());
super.setMessageWriters(codecConfigurer.getWriters());
}
@Override
protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
}
private Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
Map<String, Object> attributes = getErrorAttributes(request, false);
int status = (int) attributes.getOrDefault("status", 500);
return ServerResponse.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(attributes));
}
}
- @Order(-2) ensures this handler runs before the default one.
- All exceptions are routed to renderErrorResponse, which builds a JSON body from custom attributes and uses the status code provided by those attributes.
Bootstrapping
// Application.java
package com.example.webflux.error;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Manual verification
-
Missing parameter (error case):
- Request: GET http://localhost:8080/greet
- Response (HTTP 400): {"exception":"ApiException","message":"query parameter 'name' is required","status":400,"error":"Bad Request","path":"/greet","timestamp":"..."}
-
Valid parameter (success case):
- Request: GET http://localhost:8080/greet?name=Lin
- Response (HTTP 200, text/plain): Hi, Lin
Design notes
- Derive domain exceptions from ResponseStatusException to carry status and reason.
- Keep error payloads stable and versionable; include fields like status, error, message, path, and a machine-readable code if needed.
- Handlers remain focused on bussiness logic; conversion to error responses is centralized via ErrorAttributes and AbstractErrorWebExceptionHandler.