Filter Chain Execution: Pre-Filters, Post-Filters, and the Reactive Pipeline
Filter Chain Execution: Pre-Filters, Post-Filters, and the Reactive Pipeline
The gateway’s filter chain is where work happens. Route predicates decide where a request goes. Filters decide what happens to it along the way. Adding headers, rewriting paths, injecting tenant context, logging timing, modifying response bodies: all filters. The execution model is simple in concept and treacherous in practice.
The Filter Contract
Every filter implements the same pattern:
public interface GatewayFilter {
Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain);
}
The filter receives the exchange (request + response) and the chain (the next filter). It must call chain.filter(exchange) to continue the chain. What you do before and after that call defines your filter’s behavior.
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
// PRE-FILTER: code here runs before the downstream call
doSomethingBeforeProxy(exchange);
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
// POST-FILTER: code here runs after the response returns
doSomethingAfterProxy(exchange);
}));
}
chain.filter(exchange) returns a Mono<Void>. It completes when the downstream service responds and the response is sent to the client. The .then() operator runs after that completion.
This is not like servlet filters where you can modify the response body in the post-filter. By the time .then() runs, the response has already been streamed to the client. Response headers can be modified (they are sent before the body), but the body is gone.
Pre-Filter: Modifying the Request
Pre-filters modify the ServerHttpRequest before it reaches the downstream service. Since ServerHttpRequest is immutable, you create a mutated copy:
@Component
public class TenantContextGatewayFilterFactory
extends AbstractGatewayFilterFactory<
TenantContextGatewayFilterFactory.Config> {
private final JwtDecoder jwtDecoder;
public TenantContextGatewayFilterFactory(JwtDecoder jwtDecoder) {
super(Config.class);
this.jwtDecoder = jwtDecoder;
}
@Override
public GatewayFilter apply(Config config) {
return new OrderedGatewayFilter((exchange, chain) -> {
String authHeader = exchange.getRequest().getHeaders()
.getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
exchange.getResponse()
.setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
String token = authHeader.substring(7);
Jwt jwt = jwtDecoder.decode(token);
String tenantId = jwt.getClaimAsString("tenant_id");
String userId = jwt.getSubject();
ServerHttpRequest mutatedRequest = exchange.getRequest()
.mutate()
.header("X-Tenant-Id", tenantId)
.header("X-User-Id", userId)
.header("X-Request-Id", UUID.randomUUID().toString())
.build();
ServerWebExchange mutatedExchange = exchange.mutate()
.request(mutatedRequest)
.build();
return chain.filter(mutatedExchange);
}, config.getOrder());
}
public static class Config {
private int order = 1;
public int getOrder() { return order; }
public void setOrder(int order) { this.order = order; }
}
}
The mutation chain: request.mutate() creates a builder. .header() adds headers. .build() creates a new ServerHttpRequest. exchange.mutate().request() creates a new ServerWebExchange with the new request. The original exchange and request are unchanged.
Downstream services receive the X-Tenant-Id, X-User-Id, and X-Request-Id headers without knowing the gateway added them.
Post-Filter: Modifying the Response
Post-filters run after chain.filter() completes. They can modify response headers but not the response body (which has already been streamed):
@Component
public class ResponseHeadersFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
ServerHttpResponse response = exchange.getResponse();
HttpHeaders headers = response.getHeaders();
headers.add("X-Response-Time",
String.valueOf(System.currentTimeMillis()));
headers.add("X-Gateway-Instance",
System.getenv("HOSTNAME"));
}));
}
@Override
public int getOrder() {
return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
}
}
The order is critical. NettyWriteResponseFilter writes the response to the client. Your post-filter must run before it writes. Setting the order to WRITE_RESPONSE_FILTER_ORDER - 1 ensures your filter wraps the write filter and can modify headers before they are sent.
Built-in Filters
AddRequestHeader
Adds a static header to the downstream request:
filters:
- AddRequestHeader=X-Gateway-Source, api-gateway
Internally, it uses ServerHttpRequest.mutate().header(). The header value can include URI template variables: AddRequestHeader=X-Tenant, {tenant} uses the tenant variable extracted by a Host predicate.
RewritePath
Rewrites the request path using a regex:
filters:
- RewritePath=/api/orders/(?<segment>.*), /orders/${segment}
Request path /api/orders/123/items becomes /orders/123/items. The regex is Java’s Pattern syntax. Named groups (?<segment>) are referenced with ${segment} in the replacement.
Note the YAML escaping: backslashes in YAML must be doubled or the string must be quoted. RewritePath=/api/(?<segment>.*), /${segment} works. RewritePath=/api/(\d+), /$1 needs quoting: "RewritePath=/api/(\\d+), /$1".
StripPrefix
Removes path segments from the beginning:
filters:
- StripPrefix=1
/api/orders/123 becomes /orders/123. Simpler than RewritePath when you just need to remove a prefix.
CircuitBreaker
Integrates Resilience4J circuit breaker with a fallback:
filters:
- name: CircuitBreaker
args:
name: orderServiceCB
fallbackUri: forward:/fallback/orders
statusCodes:
- 500
- 502
- 503
When the downstream service returns 500, 502, or 503, or when the connection fails, the circuit breaker counts the failure. After the threshold is reached, the circuit opens and requests are forwarded to /fallback/orders without attempting the downstream call.
Filter Ordering
Filters implement Ordered or are wrapped in OrderedGatewayFilter. The combined list of global filters and route-specific filters is sorted by order before execution.
Default orders for key built-in global filters:
| Filter | Order |
|---|---|
RemoveCachedBodyFilter | Integer.MIN_VALUE |
AdaptCachedBodyGlobalFilter | Integer.MIN_VALUE + 1000 |
ReactiveLoadBalancerClientFilter | 10150 |
NettyRoutingFilter | Integer.MAX_VALUE |
NettyWriteResponseFilter | -1 |
Your custom filters should slot between these. Pre-filters that modify the request should have order values lower than 10150 (before load balancing). Post-filters that modify the response should have order values lower than -1 (before response writing).
// BROKEN: Custom filter with default order (0).
// Runs after some built-in filters, before others.
// Behavior depends on which built-in filters happen to have order 0.
@Component
public class MyFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
// When does this run relative to other filters? Unclear.
return chain.filter(exchange);
}
// No Ordered implementation. Default order = 0.
}
// CORRECT: Explicit ordering with clear intent.
@Component
public class MyFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 5; // After security, before load balancing
}
}
Modifying the Response Body
The common trap: trying to read or modify the response body in a post-filter.
// BROKEN: Attempting to read response body in post-filter
return chain.filter(exchange)
.then(Mono.fromRunnable(() -> {
// exchange.getResponse().getBody() is not available here.
// The body has already been written to the client.
// There is no getBody() method on ServerHttpResponse.
}));
To modify the response body, you must intercept it before it is written. Spring Cloud Gateway provides ModifyResponseBodyGatewayFilterFactory:
filters:
- name: ModifyResponseBody
args:
inClass: java.lang.String
outClass: java.lang.String
rewriteFunction: com.saas.gateway.ResponseRewriter
@Component
public class ResponseRewriter
implements RewriteFunction<String, String> {
@Override
public Publisher<String> apply(ServerWebExchange exchange,
String body) {
// body is the original response body as a String
// Return the modified body
String modified = body.replace(
"\"internalId\"", "\"id\"");
return Mono.just(modified);
}
}
ModifyResponseBodyGatewayFilter wraps the response in a ServerHttpResponseDecorator. The decorator intercepts writeWith() (the method that writes the body) and buffers the body data. Once the full body is buffered, it calls your RewriteFunction, then writes the modified body to the client.
This has a cost: the entire response body is buffered in memory. For large responses (file downloads, paginated results), this can cause OutOfMemoryError. Use ModifyResponseBody only for small, predictable response sizes. For large responses, use a ServerHttpResponseDecorator directly with streaming:
@Component
public class StreamingResponseFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange,
GatewayFilterChain chain) {
ServerHttpResponseDecorator decorator =
new ServerHttpResponseDecorator(exchange.getResponse()) {
@Override
public Mono<Void> writeWith(
Publisher<? extends DataBuffer> body) {
Flux<DataBuffer> modifiedBody = Flux.from(body)
.map(dataBuffer -> {
// Process each chunk without buffering all
byte[] bytes = new byte[
dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
// Transform bytes...
return exchange.getResponse()
.bufferFactory()
.wrap(bytes);
});
return super.writeWith(modifiedBody);
}
};
return chain.filter(
exchange.mutate().response(decorator).build());
}
@Override
public int getOrder() {
return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
}
}
This processes the response body chunk by chunk without buffering the entire response. Each DataBuffer is transformed and forwarded immediately. Memory usage stays constant regardless of response size.
The key lesson: the reactive pipeline streams data. Pre-filters modify the request before it streams out. Post-filters can touch response headers but not the body. To modify the body, you must intercept the stream with a decorator. And you must release DataBuffer objects to prevent memory leaks. Every DataBuffer you read without releasing leaks direct memory until the JVM runs out.