Describe the bug
RequestRateLimiterGatewayFilterFactory calls exchange.getResponse().setComplete() when the rate limit is exceeded.
This commits the response immediately, which has two negative side-effects:
-
Retry breaks with UnsupportedOperationException: ReadOnlyHttpHeaders.
When RetryGatewayFilterFactory is placed before the rate limiter and configured with series=CLIENT_ERROR, the retry operator sees the committed 429 status and re-executes the filter chain using the same ServerWebExchange. On the second pass, RequestRateLimiterGatewayFilterFactory attempts to add X-RateLimit-* headers to the already-committed response, causing an immediate UnsupportedOperationException from ReadOnlyHttpHeaders.
-
Custom exception handlers cannot intercept 429.
Any WebExceptionHandler or ErrorWebExceptionHandler bean is bypassed because the filter returns a successful Mono<Void> rather than propagating an error.
Regression / Prior art
This was raised in #575 (2018) where @spencergibb suggested the fix:
"I think rather than setting a status we can use ResponseStatusException in Mono.error."
That change was never applied.
Versions
- Spring Cloud Gateway Server: 4.2.7
- Spring Boot: 3.5.14
- Java: 25
Minimal reproduction
Route configuration
spring:
cloud:
gateway:
routes:
- id: ratelimit-retry-bug
uri: http://localhost:8081
predicates:
- Path=/test/**
filters:
# Retry is placed BEFORE rate limiter
- name: Retry
args:
retries: 3
series: CLIENT_ERROR
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 1
redis-rate-limiter.requestedTokens: 1
Steps
─────
1. Send a request that exceeds the rate limit.
2. Observe that CustomExceptionHandler is not invoked.
3. If Retry is present with series=CLIENT_ERROR, the application throws:
java.lang.UnsupportedOperationException: null
at org.springframework.http.ReadOnlyHttpHeaders.add(ReadOnlyHttpHeaders.java:97)
at org.springframework.cloud.gateway.filter.factory.RequestRateLimiterGatewayFilterFactory.lambda$apply$1(RequestRateLimiterGatewayFilterFactory.java:
112)
Expected behavior
─────────────────
When allowed=false, the filter should throw rather than commit the response, e.g.:
return Mono.error(new ResponseStatusException(config.getStatusCode(), "Rate limit exceeded" ));
This allows:
• ErrorWebExceptionHandler / WebExceptionHandler to customize the 429 response.
• RetryGatewayFilterFactory to decide whether to retry based on the exception (e.g. do NOT retry on ResponseStatusException), avoiding the ReadOnlyHttpHears crash.
Actual behavior
───────────────
The filter calls setResponseStatus(exchange, config.getStatusCode()) followed by exchange.getResponse().setComplete(), which commits the response and returns a successful Mono<Void>.
Suggested fix
─────────────
Replace the final setComplete() branch in RequestRateLimiterGatewayFilterFactory.apply() with Mono.error(new ResponseStatusException(...)), consistent with the suggestion in #575.
Describe the bug
RequestRateLimiterGatewayFilterFactorycallsexchange.getResponse().setComplete()when the rate limit is exceeded.This commits the response immediately, which has two negative side-effects:
Retry breaks with
UnsupportedOperationException: ReadOnlyHttpHeaders.When
RetryGatewayFilterFactoryis placed before the rate limiter and configured withseries=CLIENT_ERROR, the retry operator sees the committed429status and re-executes the filter chain using the sameServerWebExchange. On the second pass,RequestRateLimiterGatewayFilterFactoryattempts to addX-RateLimit-*headers to the already-committed response, causing an immediateUnsupportedOperationExceptionfromReadOnlyHttpHeaders.Custom exception handlers cannot intercept 429.
Any
WebExceptionHandlerorErrorWebExceptionHandlerbean is bypassed because the filter returns a successfulMono<Void>rather than propagating an error.Regression / Prior art
This was raised in #575 (2018) where @spencergibb suggested the fix:
That change was never applied.
Versions
Minimal reproduction
Route configuration