diff --git a/spring-cloud-gateway-integration-tests/httpclient/pom.xml b/spring-cloud-gateway-integration-tests/httpclient/pom.xml
new file mode 100644
index 0000000000..54472f7b5a
--- /dev/null
+++ b/spring-cloud-gateway-integration-tests/httpclient/pom.xml
@@ -0,0 +1,77 @@
+
+
+ 4.0.0
+
+ httpclient
+ jar
+
+ Spring Cloud Gateway HttpClient Integration Test
+ Spring Cloud Gateway HttpClient Integration Test
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-gateway-integration-tests
+ 4.3.0-SNAPSHOT
+ ..
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webflux
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-gateway-server-webmvc
+
+
+
+ org.springframework.retry
+ spring-retry
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-loadbalancer
+
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+
+
+
+ maven-deploy-plugin
+
+ true
+
+
+
+
+
diff --git a/spring-cloud-gateway-integration-tests/httpclient/src/main/java/org/springframework/cloud/gateway/tests/httpclient/HttpClientApplication.java b/spring-cloud-gateway-integration-tests/httpclient/src/main/java/org/springframework/cloud/gateway/tests/httpclient/HttpClientApplication.java
new file mode 100644
index 0000000000..6e198797cf
--- /dev/null
+++ b/spring-cloud-gateway-integration-tests/httpclient/src/main/java/org/springframework/cloud/gateway/tests/httpclient/HttpClientApplication.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2013-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.gateway.tests.httpclient;
+
+import java.time.Duration;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.hc.core5.util.Timeout;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
+import org.springframework.boot.http.client.HttpComponentsClientHttpRequestFactoryBuilder;
+import org.springframework.cloud.client.DefaultServiceInstance;
+import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
+import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
+import org.springframework.cloud.loadbalancer.support.ServiceInstanceListSuppliers;
+import org.springframework.context.annotation.Bean;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.function.RouterFunction;
+import org.springframework.web.servlet.function.ServerResponse;
+
+import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.prefixPath;
+import static org.springframework.cloud.gateway.server.mvc.filter.LoadBalancerFilterFunctions.lb;
+import static org.springframework.cloud.gateway.server.mvc.filter.RetryFilterFunctions.retry;
+import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
+import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
+
+/**
+ * @author jiangyuan
+ */
+@SpringBootConfiguration
+@EnableAutoConfiguration
+@LoadBalancerClient(name = "myservice", configuration = MyServiceConf.class)
+public class HttpClientApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(HttpClientApplication.class, args);
+ }
+
+ @Bean
+ public HttpComponentsClientHttpRequestFactoryBuilder httpComponentsClientHttpRequestFactoryBuilder() {
+ return ClientHttpRequestFactoryBuilder.httpComponents()
+ .withConnectionManagerCustomizer(builder -> builder.setMaxConnTotal(2).setMaxConnPerRoute(2))
+ .withDefaultRequestConfigCustomizer(
+ c -> c.setConnectionRequestTimeout(Timeout.of(Duration.ofMillis(3000))));
+ }
+
+ @Bean
+ public RouterFunction gatewayRouterFunctionsRetry() {
+ return route("test-retry").GET("/retry", http())
+ .filter(lb("myservice"))
+ .filter(prefixPath("/do"))
+ .filter(retry(3))
+ .build();
+ }
+
+ @RestController
+ protected static class RetryController {
+
+ Log log = LogFactory.getLog(getClass());
+
+ ConcurrentHashMap map = new ConcurrentHashMap<>();
+
+ @GetMapping("/do/retry")
+ public ResponseEntity retry(@RequestParam("key") String key,
+ @RequestParam(name = "count", defaultValue = "3") int count,
+ @RequestParam(name = "failStatus", required = false) Integer failStatus) {
+ AtomicInteger num = map.computeIfAbsent(key, s -> new AtomicInteger());
+ int i = num.incrementAndGet();
+ log.warn("Retry count: " + i);
+ String body = String.valueOf(i);
+ if (i < count) {
+ HttpStatus httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
+ if (failStatus != null) {
+ httpStatus = HttpStatus.resolve(failStatus);
+ }
+ return ResponseEntity.status(httpStatus).header("X-Retry-Count", body).body("temporarily broken");
+ }
+ return ResponseEntity.status(HttpStatus.OK).header("X-Retry-Count", body).body(body);
+ }
+
+ }
+
+}
+
+class MyServiceConf {
+
+ @Value("${local.server.port}")
+ private int port = 0;
+
+ @Bean
+ public ServiceInstanceListSupplier staticServiceInstanceListSupplier() {
+ return ServiceInstanceListSuppliers.from("myservice",
+ new DefaultServiceInstance("myservice-1", "myservice", "localhost", port, false));
+ }
+
+}
diff --git a/spring-cloud-gateway-integration-tests/httpclient/src/main/resources/application.yml b/spring-cloud-gateway-integration-tests/httpclient/src/main/resources/application.yml
new file mode 100644
index 0000000000..106caafa26
--- /dev/null
+++ b/spring-cloud-gateway-integration-tests/httpclient/src/main/resources/application.yml
@@ -0,0 +1,3 @@
+logging:
+ level:
+ org.springframework.cloud.gateway: TRACE
diff --git a/spring-cloud-gateway-integration-tests/httpclient/src/test/java/org/springframework/cloud/gateway/tests/httpclient/HttpClientApplicationTests.java b/spring-cloud-gateway-integration-tests/httpclient/src/test/java/org/springframework/cloud/gateway/tests/httpclient/HttpClientApplicationTests.java
new file mode 100644
index 0000000000..13269d418f
--- /dev/null
+++ b/spring-cloud-gateway-integration-tests/httpclient/src/test/java/org/springframework/cloud/gateway/tests/httpclient/HttpClientApplicationTests.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2013-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.gateway.tests.httpclient;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+/**
+ * @author jiangyuan
+ */
+@SpringBootTest(classes = HttpClientApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@DirtiesContext
+public class HttpClientApplicationTests {
+
+ @LocalServerPort
+ private int port;
+
+ @Test
+ public void retryWorks() {
+ WebTestClient client = WebTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
+ client.get().uri("/retry?key=get").exchange().expectStatus().isOk().expectBody(String.class).isEqualTo("3");
+ }
+
+}
diff --git a/spring-cloud-gateway-integration-tests/pom.xml b/spring-cloud-gateway-integration-tests/pom.xml
index 85b3c76dc0..6c0074b148 100644
--- a/spring-cloud-gateway-integration-tests/pom.xml
+++ b/spring-cloud-gateway-integration-tests/pom.xml
@@ -24,6 +24,7 @@
grpc
http2
mvc-failure-analyzer
+ httpclient
diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java
index 05c073ed2d..27bfa1e1f2 100644
--- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java
+++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java
@@ -63,6 +63,11 @@ public abstract class MvcUtils {
*/
public static final String CLIENT_RESPONSE_INPUT_STREAM_ATTR = qualify("cachedClientResponseBody");
+ /**
+ * Client response key.
+ */
+ public static final String CLIENT_RESPONSE_ATTR = qualify("cachedClientResponse");
+
/**
* CircuitBreaker execution exception attribute name.
*/
diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java
index 272b1e6ce8..4de07b1d82 100644
--- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java
+++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctions.java
@@ -33,6 +33,7 @@
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
+import org.springframework.http.client.ClientHttpResponse;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryPolicy;
import org.springframework.retry.policy.CompositeRetryPolicy;
@@ -75,6 +76,7 @@ public static HandlerFilterFunction retry(RetryC
if (config.isCacheBody()) {
MvcUtils.getOrCacheBody(request);
}
+ reset(request);
ServerResponse serverResponse = next.handle(request);
if (isRetryableStatusCode(serverResponse.statusCode(), config)
@@ -86,6 +88,14 @@ && isRetryableMethod(request.method(), config)) {
});
}
+ private static void reset(ServerRequest request) throws IOException {
+ ClientHttpResponse clientHttpResponse = MvcUtils.getAttribute(request, MvcUtils.CLIENT_RESPONSE_ATTR);
+ if (clientHttpResponse != null) {
+ clientHttpResponse.close();
+ MvcUtils.putAttribute(request, MvcUtils.CLIENT_RESPONSE_ATTR, null);
+ }
+ }
+
private static boolean isRetryableStatusCode(HttpStatusCode httpStatus, RetryConfig config) {
return config.getSeries().stream().anyMatch(series -> HttpStatus.Series.resolve(httpStatus.value()) == series);
}
diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ClientHttpRequestFactoryProxyExchange.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ClientHttpRequestFactoryProxyExchange.java
index 5c17dd2df9..db1685d9a7 100644
--- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ClientHttpRequestFactoryProxyExchange.java
+++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/ClientHttpRequestFactoryProxyExchange.java
@@ -56,6 +56,7 @@ public ServerResponse exchange(Request request) {
InputStream body = clientHttpResponse.getBody();
// put the body input stream in a request attribute so filters can read it.
MvcUtils.putAttribute(request.getServerRequest(), MvcUtils.CLIENT_RESPONSE_INPUT_STREAM_ATTR, body);
+ MvcUtils.putAttribute(request.getServerRequest(), MvcUtils.CLIENT_RESPONSE_ATTR, clientHttpResponse);
ServerResponse serverResponse = GatewayServerResponse.status(clientHttpResponse.getStatusCode())
.build((req, httpServletResponse) -> {
try (clientHttpResponse) {
diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/RestClientProxyExchange.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/RestClientProxyExchange.java
index 46e9d0326b..315a061f72 100644
--- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/RestClientProxyExchange.java
+++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/RestClientProxyExchange.java
@@ -72,6 +72,7 @@ private ServerResponse doExchange(Request request, ClientHttpResponse clientResp
InputStream body = clientResponse.getBody();
// put the body input stream in a request attribute so filters can read it.
MvcUtils.putAttribute(request.getServerRequest(), MvcUtils.CLIENT_RESPONSE_INPUT_STREAM_ATTR, body);
+ MvcUtils.putAttribute(request.getServerRequest(), MvcUtils.CLIENT_RESPONSE_ATTR, clientResponse);
ServerResponse serverResponse = GatewayServerResponse.status(clientResponse.getStatusCode())
.build((req, httpServletResponse) -> {
try (clientResponse) {