Skip to content

RequestRateLimiterGatewayFilterFactory commits 429 response instead of throwing #4175

@Talakafafi

Description

@Talakafafi

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:

  1. 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.

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions