Unified Exception Handling in Spring Cloud Gateway
In a traditional Spring MVC application, cross-cutting exception handling is typically implemented with @ControllerAdvice, allowing controllers to return a consistent response body when errors occur.
// MVC-style global exception handling (Servlet stack)
@ControllerAdvice
public class MvcGlobalExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, Object>> onAccessDenied(AccessDeniedException ex) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("code", "FORBIDDEN");
payload.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(payload);
}
}
In a microservice architecture, requests frequently pass through an API gateway. If a route is missing, the upstream service is down, or a filter/forwarding step fails, the request never reaches the backend application. Consequently, any @ControllerAdvice inside the service cannot handle the error. In these cases, errors must be captured and rendered by the gateway itself, which in Spring Cloud Gateway runs on the reactiev WebFlux stack.
Default error pipeline in Spring Cloud Gateway
Reactive error handling is integrated into the core WebFlux pipeline. The ExceptionHandlingWebHandler decorates the main WebHandler and chains registered WebExceptionHandler instances. Each handler gets a chance to transform an eror into a response.
// Simplified flow of exception handling inside WebFlux
public final class ExceptionHandlingWebHandler extends WebHandlerDecorator {
private final List<WebExceptionHandler> handlers;
@Override
public Mono<Void> handle(ServerWebExchange exchange) {
Mono<Void> flow;
try {
flow = getDelegate().handle(exchange);
} catch (Throwable t) {
flow = Mono.error(t);
}
for (WebExceptionHandler h : handlers) {
flow = flow.onErrorResume(err -> h.handle(exchange, err));
}
return flow;
}
}
The default implementation, DefaultErrorWebExceptionHandler, decides how to render the response based on the Accept header, serving HTML for browsers or a JSON payload for API clients.
// DefaultErrorWebExceptionHandler routing logic (simplified)
protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
return route(acceptsTextHtml(), this::renderErrorView)
.andRoute(RequestPredicates.all(), this::renderErrorResponse);
}
Example: requesting JSON produces a structured error response.
curl -H 'Accept: application/json' "http://localhost:9999/nonexistent/path"
# {"timestamp":"2020-05-24T18:09:24Z","path":"/nonexistent/path","status":404,"error":"Not Found","message":null,"requestId":"083c48e3-2"}
Customizing a global ErrorWebExceptionHandler for the gateway
To ensure consistent JSON error output from the gateway regardless of client type or route failure mode, implement ErrorWebExceptionHandler and register it in the application context. Use a higher precedence than ResponseStatusExceptionHandler if you intend to set the status code yourself. The example below sets the HTTP status based on the Throwable when possible and always writes a JSON body.
@Order(-2) // higher precedence than ResponseStatusExceptionHandler (order 0)
public class GatewayGlobalErrorHandler implements ErrorWebExceptionHandler {
private final ObjectMapper json;
public GatewayGlobalErrorHandler(ObjectMapper json) {
this.json = json;
}
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable error) {
ServerHttpResponse response = exchange.getResponse();
if (response.isCommitted()) {
return Mono.error(error);
}
// Always emit JSON
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
HttpStatus status = resolveStatus(error);
response.setStatusCode(status);
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
body.put("path", exchange.getRequest().getPath().value());
body.put("status", status.value());
body.put("error", status.getReasonPhrase());
body.put("message", Optional.ofNullable(error.getMessage()).orElse(""));
body.put("requestId", Optional.ofNullable(exchange.getRequest().getId()).orElse(""));
byte[] bytes;
try {
bytes = json.writeValueAsBytes(body);
} catch (JsonProcessingException jpe) {
bytes = ("{\"status\":" + status.value() + "}").getBytes(StandardCharsets.UTF_8);
}
DataBuffer buffer = response.bufferFactory().wrap(bytes);
return response.writeWith(Mono.just(buffer));
}
private HttpStatus resolveStatus(Throwable error) {
if (error instanceof ResponseStatusException rse) {
return rse.getStatusCode();
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
Register the handler as a bean in the gateway application so that it participates in the WebFlux error chain.
@Configuration
public class GatewayErrorHandlerConfig {
@Bean
@Order(-2)
public ErrorWebExceptionHandler errorWebExceptionHandler(ObjectMapper objectMapper) {
return new GatewayGlobalErrorHandler(objectMapper);
}
}
Notes on ordering and behavior
- WebFlux executes WebExceptionHandler beans in ascending order value (lower value runs first). Placing the custom handler at order -2 ensures it runs before ResponseStatusExceptionHandler (order 0).
- If you prefer the framework to set the status for ResponseStatusException and only want to render the body, give your handler a higher order value (e.g., 1). However, an earlier handler might mark the response complete, preventing later handlers from writing; settting status inside your handler avoids this conflict.
- The handler above forces aplication/json for all errors at the gateway level and returns a stable structure regardless of the client’s Accept header.
- Tested against Spring Cloud Hoxton.SR4 and Spring Boot 2.3.x; adjust APIs if you target newer baselines where method names or types may have evolved.