Skip to content

Commit b9b8dde

Browse files
authored
Merge pull request #3970 from ryanjbaxter/framework-retry
Retry filter based on Spring Framework's retry functionality
2 parents 5b04856 + 3a4ca16 commit b9b8dde

File tree

14 files changed

+1254
-94
lines changed

14 files changed

+1254
-94
lines changed

docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/filters/retry.adoc

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
[[retry-filter]]
22
= `Retry` Filter
33

4+
The `Retry` filter automatically selects the appropriate retry implementation based on what is available on the classpath:
5+
6+
* If https://github.com/spring-projects/spring-retry[Spring Retry] is on the classpath, the filter uses `GatewayRetryFilterFunctions` (based on Spring Retry) by default.
7+
* If Spring Retry is not on the classpath, the filter automatically uses `FrameworkRetryFilterFunctions` (based on the retry functionality in https://docs.spring.io/spring-framework/reference/7.0-SNAPSHOT/core/resilience.html[Spring Framework 7]).
8+
9+
WARNING: Spring Retry has been placed in maintenance-only mode. Once Spring Retry is no longer being maintained, the `Retry` filter will exclusively use the Framework retry implementation (`FrameworkRetryFilterFunctions`), and the Spring Retry-based implementation will be removed.
10+
11+
TIP: You can force the use of the Framework retry filter even when Spring Retry is on the classpath by setting `spring.cloud.gateway.server.webmvc.use-framework-retry-filter=true` in your configuration.
12+
413
The `Retry` filter supports the following parameters:
514

615
* `retries`: The number of retries that should be attempted.
@@ -96,18 +105,40 @@ The following two example routes are equivalent:
96105
spring:
97106
cloud:
98107
gateway:
99-
routes:
100-
- id: retry_route
101-
uri: https://example.org
102-
filters:
103-
- name: Retry
104-
args:
105-
retries: 3
106-
statuses: INTERNAL_SERVER_ERROR
107-
methods: GET
108-
- id: retryshortcut_route
109-
uri: https://example.org
110-
filters:
111-
- Retry=3,INTERNAL_SERVER_ERROR,GET
108+
mvc:
109+
routes:
110+
- id: retry_route
111+
uri: https://example.org
112+
filters:
113+
- name: Retry
114+
args:
115+
retries: 3
116+
statuses: INTERNAL_SERVER_ERROR
117+
methods: GET
118+
- id: retryshortcut_route
119+
uri: https://example.org
120+
filters:
121+
- Retry=3,INTERNAL_SERVER_ERROR,GET
122+
----
123+
124+
== Forcing Framework Retry Filter
125+
126+
When Spring Retry is on the classpath, the `Retry` filter uses the Spring Retry-based implementation by default. To force the use of the Framework retry filter instead, set the following property:
127+
128+
.application.yml
129+
[source,yaml]
130+
----
131+
spring:
132+
cloud:
133+
gateway:
134+
server:
135+
webmvc:
136+
use-framework-retry-filter: true
137+
----
138+
139+
.application.properties
140+
[source,properties]
141+
----
142+
spring.cloud.gateway.server.webmvc.use-framework-retry-filter=true
112143
----
113144

spring-cloud-gateway-integration-tests/httpclient/src/main/java/org/springframework/cloud/gateway/tests/httpclient/HttpClientApplication.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@
4747
import org.springframework.web.servlet.function.ServerResponse;
4848

4949
import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.prefixPath;
50+
import static org.springframework.cloud.gateway.server.mvc.filter.FrameworkRetryFilterFunctions.frameworkRetry;
51+
import static org.springframework.cloud.gateway.server.mvc.filter.GatewayRetryFilterFunctions.retry;
5052
import static org.springframework.cloud.gateway.server.mvc.filter.LoadBalancerFilterFunctions.lb;
51-
import static org.springframework.cloud.gateway.server.mvc.filter.RetryFilterFunctions.retry;
5253
import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
5354
import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
5455

@@ -89,13 +90,29 @@ public RouterFunction<ServerResponse> gatewayRouterFunctionsRetry() {
8990
.build();
9091
}
9192

93+
@Bean
94+
public RouterFunction<ServerResponse> gatewayRouterFunctionsFrameworkRetry() {
95+
return route("test-retry").GET("/frameworkretry", http())
96+
.filter(lb("myservice"))
97+
.filter(prefixPath("/do"))
98+
.filter(frameworkRetry(3))
99+
.build();
100+
}
101+
92102
@RestController
93103
protected static class RetryController {
94104

95105
Log log = LogFactory.getLog(getClass());
96106

97107
ConcurrentHashMap<String, AtomicInteger> map = new ConcurrentHashMap<>();
98108

109+
@GetMapping("/do/frameworkretry")
110+
public ResponseEntity<String> frameworkRetry(@RequestParam("key") String key,
111+
@RequestParam(name = "count", defaultValue = "3") int count,
112+
@RequestParam(name = "failStatus", required = false) Integer failStatus) {
113+
return retry(key, count, failStatus);
114+
}
115+
99116
@GetMapping("/do/retry")
100117
public ResponseEntity<String> retry(@RequestParam("key") String key,
101118
@RequestParam(name = "count", defaultValue = "3") int count,
@@ -111,6 +128,7 @@ public ResponseEntity<String> retry(@RequestParam("key") String key,
111128
}
112129
return ResponseEntity.status(httpStatus).header("X-Retry-Count", body).body("temporarily broken");
113130
}
131+
map = new ConcurrentHashMap<>();
114132
return ResponseEntity.status(HttpStatus.OK).header("X-Retry-Count", body).body(body);
115133
}
116134

spring-cloud-gateway-integration-tests/httpclient/src/test/java/org/springframework/cloud/gateway/tests/httpclient/HttpClientApplicationTests.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,16 @@ public void retryWorks() {
3939
client.get().uri("/retry?key=get").exchange().expectStatus().isOk().expectBody(String.class).isEqualTo("3");
4040
}
4141

42+
@Test
43+
public void frameworkRetryWorks() {
44+
WebTestClient client = WebTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
45+
client.get()
46+
.uri("/frameworkretry?key=get")
47+
.exchange()
48+
.expectStatus()
49+
.isOk()
50+
.expectBody(String.class)
51+
.isEqualTo("3");
52+
}
53+
4254
}

spring-cloud-gateway-server-webmvc/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@
9898
<optional>true</optional>
9999
</dependency>
100100
<!-- Spring test dependencies -->
101+
<dependency>
102+
<groupId>org.springframework.cloud</groupId>
103+
<artifactId>spring-cloud-test-support</artifactId>
104+
<scope>test</scope>
105+
</dependency>
101106
<dependency>
102107
<groupId>org.springframework.boot</groupId>
103108
<artifactId>spring-boot-starter-test</artifactId>

spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcProperties.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ public class GatewayMvcProperties {
6868
*/
6969
private String trustedProxies;
7070

71+
/**
72+
* In the case where Spring Retry is on the classpath but you still want to use Spring
73+
* Framework retry as your retry filter, set this property to true.
74+
*/
75+
private boolean useFrameworkRetryFilter = false;
76+
7177
public List<RouteProperties> getRoutes() {
7278
return routes;
7379
}
@@ -92,6 +98,14 @@ public void setStreamingMediaTypes(List<MediaType> streamingMediaTypes) {
9298
this.streamingMediaTypes = streamingMediaTypes;
9399
}
94100

101+
public boolean isUseFrameworkRetryFilter() {
102+
return useFrameworkRetryFilter;
103+
}
104+
105+
public void setUseFrameworkRetryFilter(boolean useFrameworkRetryFilter) {
106+
this.useFrameworkRetryFilter = useFrameworkRetryFilter;
107+
}
108+
95109
public int getStreamingBufferSize() {
96110
return streamingBufferSize;
97111
}

spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/filter/FilterAutoConfiguration.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@
2525
import org.springframework.boot.autoconfigure.AutoConfiguration;
2626
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
2727
import org.springframework.cloud.client.circuitbreaker.CircuitBreaker;
28+
import org.springframework.cloud.gateway.server.mvc.config.GatewayMvcProperties;
2829
import org.springframework.cloud.gateway.server.mvc.config.RouteProperties;
2930
import org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctionDefinition;
3031
import org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions;
3132
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
3233
import org.springframework.context.annotation.Bean;
3334
import org.springframework.context.annotation.Configuration;
34-
import org.springframework.retry.support.RetryTemplate;
3535
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
3636

3737
@AutoConfiguration
@@ -83,11 +83,17 @@ public Function<RouteProperties, HandlerFunctionDefinition> lbHandlerFunctionDef
8383
}
8484

8585
@Configuration(proxyBeanMethods = false)
86-
@ConditionalOnClass(RetryTemplate.class)
8786
static class RetryFilterConfiguration {
8887

88+
private final GatewayMvcProperties properties;
89+
90+
RetryFilterConfiguration(GatewayMvcProperties properties) {
91+
this.properties = properties;
92+
}
93+
8994
@Bean
9095
public RetryFilterFunctions.FilterSupplier retryFilterFunctionsSupplier() {
96+
RetryFilterFunctions.setUseFrameworkRetry(properties.isUseFrameworkRetryFilter());
9197
return new RetryFilterFunctions.FilterSupplier();
9298
}
9399

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright 2013-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.gateway.server.mvc.filter;
18+
19+
import java.io.IOException;
20+
import java.util.function.Consumer;
21+
22+
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
23+
import org.springframework.core.retry.RetryPolicy;
24+
import org.springframework.core.retry.RetryTemplate;
25+
import org.springframework.http.HttpMethod;
26+
import org.springframework.http.HttpStatus;
27+
import org.springframework.http.HttpStatusCode;
28+
import org.springframework.http.client.ClientHttpResponse;
29+
import org.springframework.web.servlet.function.HandlerFilterFunction;
30+
import org.springframework.web.servlet.function.ServerRequest;
31+
import org.springframework.web.servlet.function.ServerResponse;
32+
33+
/**
34+
* @author Ryan Baxter
35+
* @deprecated Once Spring Retry is no longer being maintained, we will remove this class
36+
* and move the logic to @link GatewayRetryFilterFunctions. Retry filter based on retry
37+
* functionality in Spring Framework.
38+
*/
39+
@Deprecated
40+
public abstract class FrameworkRetryFilterFunctions {
41+
42+
private FrameworkRetryFilterFunctions() {
43+
}
44+
45+
public static HandlerFilterFunction<ServerResponse, ServerResponse> frameworkRetry(int retries) {
46+
return frameworkRetry(config -> config.setRetries(retries));
47+
}
48+
49+
public static HandlerFilterFunction<ServerResponse, ServerResponse> frameworkRetry(
50+
Consumer<RetryFilterFunctions.RetryConfig> configConsumer) {
51+
RetryFilterFunctions.RetryConfig config = new RetryFilterFunctions.RetryConfig();
52+
configConsumer.accept(config);
53+
return frameworkRetry(config);
54+
}
55+
56+
public static HandlerFilterFunction<ServerResponse, ServerResponse> frameworkRetry(
57+
RetryFilterFunctions.RetryConfig config) {
58+
return (request, next) -> {
59+
CompositeRetryPolicy compositeRetryPolicy = new CompositeRetryPolicy(config);
60+
61+
RetryTemplate retryTemplate = new RetryTemplate();
62+
retryTemplate.setRetryPolicy(compositeRetryPolicy);
63+
64+
return retryTemplate.execute(() -> {
65+
if (config.isCacheBody()) {
66+
MvcUtils.getOrCacheBody(request);
67+
}
68+
reset(request);
69+
ServerResponse serverResponse = next.handle(request);
70+
71+
if (isRetryableStatusCode(serverResponse.statusCode(), config)
72+
&& isRetryableMethod(request.method(), config)) {
73+
// use this to transfer information for HTTP status retry logic
74+
throw new RetryFilterFunctions.RetryException(request, serverResponse);
75+
}
76+
return serverResponse;
77+
});
78+
};
79+
}
80+
81+
private static void reset(ServerRequest request) throws IOException {
82+
ClientHttpResponse clientHttpResponse = MvcUtils.getAttribute(request, MvcUtils.CLIENT_RESPONSE_ATTR);
83+
if (clientHttpResponse != null) {
84+
clientHttpResponse.close();
85+
MvcUtils.putAttribute(request, MvcUtils.CLIENT_RESPONSE_ATTR, null);
86+
}
87+
}
88+
89+
private static boolean isRetryableStatusCode(HttpStatusCode httpStatus, RetryFilterFunctions.RetryConfig config) {
90+
return config.getSeries().stream().anyMatch(series -> HttpStatus.Series.resolve(httpStatus.value()) == series);
91+
}
92+
93+
private static boolean isRetryableMethod(HttpMethod method, RetryFilterFunctions.RetryConfig config) {
94+
return config.getMethods().contains(method);
95+
}
96+
97+
/**
98+
* Composite retry policy that combines exception-based and HTTP status-based retry
99+
* logic. Each instance is used for a single request execution, so we can use a
100+
* regular instance variable instead of ThreadLocal.
101+
*/
102+
private static class CompositeRetryPolicy implements RetryPolicy {
103+
104+
private final RetryFilterFunctions.RetryConfig config;
105+
106+
private int attemptCount = 0;
107+
108+
CompositeRetryPolicy(RetryFilterFunctions.RetryConfig config) {
109+
this.config = config;
110+
}
111+
112+
@Override
113+
public boolean shouldRetry(Throwable throwable) {
114+
// If no throwable, don't retry
115+
if (throwable == null) {
116+
return false;
117+
}
118+
119+
// Check if we've exceeded max attempts
120+
// Note: config.getRetries() represents max attempts (including initial
121+
// attempt)
122+
// attemptCount tracks the number of attempts made so far (0 = first attempt,
123+
// 1 = second attempt, etc.)
124+
// shouldRetry is called after each failed attempt, so:
125+
// - First failure: attemptCount = 0, we can retry (will become attempt 1)
126+
// - Second failure: attemptCount = 1, we can retry (will become attempt 2)
127+
// - Third failure: attemptCount = 2, we can retry (will become attempt 3)
128+
// - After third failure: attemptCount = 3, we've reached max attempts, stop
129+
// So if retries=3, we allow attempts 0, 1, 2 (3 total attempts)
130+
if (attemptCount >= config.getRetries()) {
131+
return false;
132+
}
133+
134+
boolean shouldRetry = false;
135+
136+
// Check if it's an HTTP status retry case
137+
if (throwable instanceof RetryFilterFunctions.RetryException retryException) {
138+
shouldRetry = isRetryableStatusCode(retryException.getResponse().statusCode(), config)
139+
&& isRetryableMethod(retryException.getRequest().method(), config);
140+
}
141+
else {
142+
// Check exception-based retry
143+
shouldRetry = config.getExceptions()
144+
.stream()
145+
.anyMatch(exceptionClass -> exceptionClass.isInstance(throwable));
146+
}
147+
148+
// If we should retry based on exception/status, increment counter
149+
// The check above ensures we won't exceed max attempts
150+
if (shouldRetry) {
151+
attemptCount++;
152+
return true;
153+
}
154+
155+
return false;
156+
}
157+
158+
}
159+
160+
}

0 commit comments

Comments
 (0)