diff --git a/spring-cloud-gateway-core/pom.xml b/spring-cloud-gateway-core/pom.xml index 956738f4a..7c5d04cfa 100644 --- a/spring-cloud-gateway-core/pom.xml +++ b/spring-cloud-gateway-core/pom.xml @@ -78,6 +78,10 @@ ${kotlin.version} true + + io.projectreactor.addons + reactor-extra + org.springframework.cloud spring-cloud-starter-netflix-eureka-client diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index e68aea072..2c954567f 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -53,6 +53,7 @@ import org.springframework.cloud.gateway.filter.factory.RemoveNonProxyHeadersGat import org.springframework.cloud.gateway.filter.factory.RemoveRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RemoveResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RequestRateLimiterGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.RetryGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RewritePathGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.SaveSessionGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory; @@ -380,6 +381,11 @@ public class GatewayAutoConfiguration { return new RewritePathGatewayFilterFactory(); } + @Bean + public RetryGatewayFilterFactory retryGatewayFilterFactory() { + return new RetryGatewayFilterFactory(); + } + @Bean public SetPathGatewayFilterFactory setPathGatewayFilterFactory() { return new SetPathGatewayFilterFactory(); diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactory.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactory.java new file mode 100644 index 000000000..82a2710c6 --- /dev/null +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactory.java @@ -0,0 +1,46 @@ +/* + * Copyright 2013-2018 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 + * + * http://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.filter.factory; + +import java.util.function.Predicate; + +import org.springframework.http.HttpMethod; +import reactor.retry.DefaultRepeat; +import reactor.retry.Repeat; +import reactor.retry.RepeatContext; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.tuple.Tuple; +import org.springframework.web.server.ServerWebExchange; + +public class RetryGatewayFilterFactory implements GatewayFilterFactory { + @Override + public GatewayFilter apply(Tuple args) { + return (exchange, chain) -> { + Predicate> predicate = context -> { + ServerWebExchange ex = (ServerWebExchange) context.applicationContext(); + boolean retryableStatusCode = ex.getResponse().getStatusCode().is5xxServerError(); + boolean retryableMethod = ex.getRequest().getMethod().equals(HttpMethod.GET); + return retryableMethod && retryableStatusCode; + }; + Repeat repeat = DefaultRepeat.create(predicate, 4) + .withApplicationContext(exchange); + return chain.filter(exchange).repeatWhen(repeat).next(); + }; + } +} diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index bdc1732f7..74dafbd74 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -38,6 +38,7 @@ import org.springframework.cloud.gateway.filter.factory.RemoveNonProxyHeadersGat import org.springframework.cloud.gateway.filter.factory.RemoveRequestHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RemoveResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RequestRateLimiterGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.RetryGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RewritePathGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.SaveSessionGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory; @@ -202,6 +203,10 @@ public class GatewayFilterSpec extends UriSpec { return filter(getBean(RewritePathGatewayFilterFactory.class).apply(regex, replacement)); } + public GatewayFilterSpec retry() { + return filter(getBean(RetryGatewayFilterFactory.class).apply(EMPTY_TUPLE)); + } + public GatewayFilterSpec secureHeaders() { return filter(getBean(SecureHeadersGatewayFilterFactory.class).apply(EMPTY_TUPLE)); } diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java new file mode 100644 index 000000000..772a6d425 --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/RetryGatewayFilterFactoryIntegrationTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2013-2018 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 + * + * http://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.filter.factory; + +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.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Import; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +public class RetryGatewayFilterFactoryIntegrationTests extends BaseWebClientTests { + + @Test + public void retryFilterGet() { + testClient.get() + .uri("/retry?key=get") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("3"); + } + + @Test + //TODO: support post + public void retryFilterPost() { + testClient.post() + .uri("/retry?key=post") + .exchange() + .expectStatus().is5xxServerError(); + // .expectBody(String.class).isEqualTo("3"); + } + + @RestController + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + Log log = LogFactory.getLog(getClass()); + + ConcurrentHashMap map = new ConcurrentHashMap<>(); + + @RequestMapping("/httpbin/retry") + public String retry(@RequestParam("key") String key) { + AtomicInteger count = map.computeIfAbsent(key, s -> new AtomicInteger()); + int i = count.incrementAndGet(); + log.warn("Retry count: "+i); + if (i < 3) { + throw new RuntimeException("temporarily broken"); + } + return String.valueOf(i); + } + } + +} diff --git a/spring-cloud-gateway-core/src/test/resources/application.yml b/spring-cloud-gateway-core/src/test/resources/application.yml index ef5b8be1b..73a063ffe 100644 --- a/spring-cloud-gateway-core/src/test/resources/application.yml +++ b/spring-cloud-gateway-core/src/test/resources/application.yml @@ -131,6 +131,14 @@ spring: - AddResponseHeader=X-Request-Foo, Bar - RemoveResponseHeader=X-Request-Foo + # ===================================== + - id: retry_test + uri: ${test.uri} + predicates: + - Path=/retry + filters: + - Retry + # ===================================== - id: secure_headers_test uri: ${test.uri}