Merge branch '4.2.x'

This commit is contained in:
Ryan Baxter
2025-05-15 10:06:14 -04:00
9 changed files with 272 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>httpclient</artifactId>
<packaging>jar</packaging>
<name>Spring Cloud Gateway HttpClient Integration Test</name>
<description>Spring Cloud Gateway HttpClient Integration Test</description>
<properties>
</properties>
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway-integration-tests</artifactId>
<version>4.1.8-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-mvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,132 @@
/*
* 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.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
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.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.http.client.HttpComponentsClientHttpRequestFactory;
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 HttpComponentsClientHttpRequestFactory httpComponentsClientHttpRequestFactory() {
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(2);
connectionManager.setDefaultMaxPerRoute(2);
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(
RequestConfig.custom().setConnectionRequestTimeout(Timeout.of(Duration.ofMillis(3000))).build())
.build();
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient);
return factory;
}
@Bean
public RouterFunction<ServerResponse> 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<String, AtomicInteger> map = new ConcurrentHashMap<>();
@GetMapping("/do/retry")
public ResponseEntity<String> 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));
}
}

View File

@@ -0,0 +1,3 @@
logging:
level:
org.springframework.cloud.gateway: TRACE

View File

@@ -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");
}
}

View File

@@ -24,6 +24,7 @@
<module>grpc</module>
<module>http2</module>
<module>mvc-failure-analyzer</module>
<module>httpclient</module>
</modules>
<build>

View File

@@ -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.
*/

View File

@@ -33,6 +33,7 @@ import org.springframework.core.NestedRuntimeException;
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 abstract class RetryFilterFunctions {
if (config.isCacheBody()) {
MvcUtils.getOrCacheBody(request);
}
reset(request);
ServerResponse serverResponse = next.handle(request);
if (isRetryableStatusCode(serverResponse.statusCode(), config)
@@ -86,6 +88,14 @@ public abstract class RetryFilterFunctions {
});
}
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);
}

View File

@@ -56,6 +56,7 @@ public class ClientHttpRequestFactoryProxyExchange extends AbstractProxyExchange
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) {

View File

@@ -72,6 +72,7 @@ public class RestClientProxyExchange extends AbstractProxyExchange {
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) {