spring-cloud / spring-cloud/spring-cloud-gateway

RequestRateLimiterGatewayFilterFactory commits 429 response instead of throwing

Open Beginner friendly
#4,175 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

waiting-for-triage
Dominant language
Java
Stars
4.9k
Forks
3.5k
Avg merge
20h 57m
Merged PRs (30d)
8

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

The affected entry point is RequestRateLimiterGatewayFilterFactory.apply(), specifically the allowed=false branch described in the issue. Read the existing response-status/setComplete path and the RetryGatewayFilterFactory interaction, then verify with the supplied retry reproduction that 429 handling reaches exception handlers without reusing a committed response.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, spring, spring-boot
Domain
api, backend
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.