From d0edc052777e011902ed942cc24ab28763223379 Mon Sep 17 00:00:00 2001 From: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:14:45 +0900 Subject: [PATCH] Skip rate-limit header mutation on committed response RequestRateLimiterGatewayFilterFactory adds the rate-limiter response headers via getHeaders().add(). When the filter chain is re-subscribed with the same ServerWebExchange (for example by the retry filter after a denied request), the response is already committed and getHeaders() returns ReadOnlyHttpHeaders, so the add() call fails with UnsupportedOperationException. Guard the header mutation with ServerHttpResponse.isCommitted() so the filter no longer throws when re-applied to a committed response. The default complete-signal behavior is unchanged. Fixes gh-4175 Signed-off-by: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> --- ...equestRateLimiterGatewayFilterFactory.java | 10 +++++-- ...tRateLimiterGatewayFilterFactoryTests.java | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java index c653c64f9..a729e1498 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java @@ -131,8 +131,14 @@ public GatewayFilter apply(Config config) { } return limiter.isAllowed(routeId, key).flatMap(response -> { - for (Map.Entry header : response.getHeaders().entrySet()) { - exchange.getResponse().getHeaders().add(header.getKey(), header.getValue()); + // The response may already be committed when the filter chain is + // re-subscribed with the same exchange (for example by the retry + // filter). Adding headers to a committed response would throw + // UnsupportedOperationException from ReadOnlyHttpHeaders. + if (!exchange.getResponse().isCommitted()) { + for (Map.Entry header : response.getHeaders().entrySet()) { + exchange.getResponse().getHeaders().add(header.getKey(), header.getValue()); + } } if (response.isAllowed()) { diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactoryTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactoryTests.java index 1df0cedb1..d5a67e6d9 100644 --- a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactoryTests.java +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactoryTests.java @@ -105,6 +105,36 @@ public void emptyKeyDeniedWithThrowOnLimit() { assertFilterFactory(exchange -> Mono.empty(), null, true, HttpStatus.FORBIDDEN, true, true); } + @Test + public void deniedDoesNotMutateHeadersWhenResponseAlreadyCommitted() { + // gh-4175: when the filter chain is re-subscribed with the same exchange (for + // example by the retry filter), the response may already be committed. Adding the + // rate-limit headers to a committed response throws UnsupportedOperationException + // (ReadOnlyHttpHeaders), so the filter must skip the header mutation. + String key = "notallowedkey"; + Map headers = Collections.singletonMap("X-Tokens-Remaining", "0"); + when(rateLimiter.isAllowed("myroute", key)).thenReturn(Mono.just(new Response(false, headers))); + + MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); + MockServerWebExchange exchange = MockServerWebExchange.from(request); + exchange.getAttributes() + .put(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR, + Route.async().id("myroute").predicate(ex -> true).uri("http://localhost").build()); + + // Simulate a previous subscription having already committed the response. + exchange.getResponse().setComplete().block(); + assertThat(exchange.getResponse().isCommitted()).isTrue(); + + RequestRateLimiterGatewayFilterFactory factory = this.context + .getBean(RequestRateLimiterGatewayFilterFactory.class); + GatewayFilter filter = factory.apply(config -> { + config.setRouteId("myroute"); + config.setKeyResolver(resolver2); + }); + + StepVerifier.create(filter.filter(exchange, this.filterChain)).expectComplete().verify(); + } + private void assertFilterFactory(KeyResolver keyResolver, String key, boolean allowed, HttpStatus expectedStatus) { assertFilterFactory(keyResolver, key, allowed, expectedStatus, null, false); }