Adds support for retrying customizable list of exceptions.
fixes gh-223
This commit is contained in:
@@ -17,57 +17,114 @@
|
||||
|
||||
package org.springframework.cloud.gateway.filter.factory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.retry.Repeat;
|
||||
import reactor.retry.RepeatContext;
|
||||
import reactor.retry.Retry;
|
||||
import reactor.retry.RetryContext;
|
||||
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilter;
|
||||
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatus.Series;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
public class RetryGatewayFilterFactory extends AbstractGatewayFilterFactory<RetryGatewayFilterFactory.Retry> {
|
||||
public class RetryGatewayFilterFactory extends AbstractGatewayFilterFactory<RetryGatewayFilterFactory.RetryConfig> {
|
||||
private static final Log log = LogFactory.getLog(RetryGatewayFilterFactory.class);
|
||||
|
||||
public RetryGatewayFilterFactory() {
|
||||
super(Retry.class);
|
||||
super(RetryConfig.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GatewayFilter apply(Retry retry) {
|
||||
retry.validate();
|
||||
public GatewayFilter apply(RetryConfig retryConfig) {
|
||||
retryConfig.validate();
|
||||
|
||||
Predicate<? super RepeatContext<ServerWebExchange>> predicate = context -> {
|
||||
ServerWebExchange exchange = context.applicationContext();
|
||||
if (exceedsMaxIterations(exchange, retryConfig)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
HttpStatus statusCode = exchange.getResponse().getStatusCode();
|
||||
HttpMethod httpMethod = exchange.getRequest().getMethod();
|
||||
|
||||
boolean retryableStatusCode = retry.getStatuses().contains(statusCode);
|
||||
boolean retryableStatusCode = retryConfig.getStatuses().contains(statusCode);
|
||||
|
||||
if (!retryableStatusCode) {
|
||||
if (!retryableStatusCode && statusCode != null) { // null status code might mean a network exception?
|
||||
// try the series
|
||||
retryableStatusCode = retry.getSeries().stream()
|
||||
retryableStatusCode = retryConfig.getSeries().stream()
|
||||
.anyMatch(series -> statusCode.series().equals(series));
|
||||
}
|
||||
|
||||
boolean retryableMethod = retry.getMethods().contains(httpMethod);
|
||||
boolean retryableMethod = retryConfig.getMethods().contains(httpMethod);
|
||||
return retryableMethod && retryableStatusCode;
|
||||
};
|
||||
|
||||
Repeat<ServerWebExchange> repeat = Repeat.create(predicate, retry.getRetries());
|
||||
Repeat<ServerWebExchange> repeat = Repeat.onlyIf(predicate)
|
||||
.doOnRepeat(context -> reset(context.applicationContext()));
|
||||
|
||||
//TODO: support timeout, backoff, jitter, etc... in Builder
|
||||
return apply(repeat);
|
||||
|
||||
Predicate<RetryContext<ServerWebExchange>> retryContextPredicate = context -> {
|
||||
if (exceedsMaxIterations(context.applicationContext(), retryConfig)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (Class<? extends Throwable> clazz : retryConfig.getExceptions()) {
|
||||
if (clazz.isInstance(context.exception())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
Retry<ServerWebExchange> reactorRetry = Retry.onlyIf(retryContextPredicate)
|
||||
.doOnRetry(context -> reset(context.applicationContext()))
|
||||
.retryMax(retryConfig.getRetries());
|
||||
return apply(repeat, reactorRetry);
|
||||
}
|
||||
|
||||
public boolean exceedsMaxIterations(ServerWebExchange exchange, RetryConfig retryConfig) {
|
||||
Integer iteration = exchange.getAttribute("retry_iteration");
|
||||
|
||||
//TODO: deal with null iteration
|
||||
return iteration != null && iteration >= retryConfig.getRetries();
|
||||
}
|
||||
|
||||
public void reset(ServerWebExchange exchange) {
|
||||
//TODO: what else to do to reset SWE?
|
||||
exchange.getAttributes().remove(ServerWebExchangeUtils.GATEWAY_ALREADY_ROUTED_ATTR);
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public GatewayFilter apply(Repeat<ServerWebExchange> repeat) {
|
||||
return (exchange, chain) -> chain.filter(exchange).repeatWhen(
|
||||
repeat.withApplicationContext(exchange)).next();
|
||||
return apply(repeat, Retry.onlyIf(ctxt -> false));
|
||||
}
|
||||
|
||||
public GatewayFilter apply(Repeat<ServerWebExchange> repeat, Retry<ServerWebExchange> retry) {
|
||||
return (exchange, chain) -> {
|
||||
log.trace("Entering retry-filter");
|
||||
|
||||
int iteration = exchange.getAttributeOrDefault("retry_iteration", -1);
|
||||
exchange.getAttributes().put("retry_iteration", iteration + 1);
|
||||
|
||||
return Mono.fromDirect(chain.filter(exchange)
|
||||
.log("retry-filter", Level.INFO)
|
||||
.retryWhen(retry.withApplicationContext(exchange))
|
||||
.repeatWhen(repeat.withApplicationContext(exchange)));
|
||||
};
|
||||
}
|
||||
|
||||
private static <T> List<T> toList(T item) {
|
||||
@@ -76,7 +133,7 @@ public class RetryGatewayFilterFactory extends AbstractGatewayFilterFactory<Retr
|
||||
return list;
|
||||
}
|
||||
|
||||
public static class Retry {
|
||||
public static class RetryConfig {
|
||||
private int retries = 3;
|
||||
|
||||
private List<Series> series = toList(Series.SERVER_ERROR);
|
||||
@@ -84,31 +141,38 @@ public class RetryGatewayFilterFactory extends AbstractGatewayFilterFactory<Retr
|
||||
private List<HttpStatus> statuses = new ArrayList<>();
|
||||
|
||||
private List<HttpMethod> methods = toList(HttpMethod.GET);
|
||||
|
||||
public Retry setRetries(int retries) {
|
||||
|
||||
private List<Class<? extends Throwable>> exceptions = toList(IOException.class);
|
||||
|
||||
public RetryConfig setRetries(int retries) {
|
||||
this.retries = retries;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Retry setSeries(Series... series) {
|
||||
public RetryConfig setSeries(Series... series) {
|
||||
this.series = Arrays.asList(series);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Retry setStatuses(HttpStatus... statuses) {
|
||||
public RetryConfig setStatuses(HttpStatus... statuses) {
|
||||
this.statuses = Arrays.asList(statuses);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Retry setMethods(HttpMethod... methods) {
|
||||
public RetryConfig setMethods(HttpMethod... methods) {
|
||||
this.methods = Arrays.asList(methods);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Retry allMethods() {
|
||||
public RetryConfig allMethods() {
|
||||
return setMethods(HttpMethod.values());
|
||||
}
|
||||
|
||||
public RetryConfig setExceptions(Class<? extends Throwable>... exceptions) {
|
||||
this.exceptions = Arrays.asList(exceptions);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void validate() {
|
||||
Assert.isTrue(this.retries > 0, "retries must be greater than 0");
|
||||
Assert.isTrue(!this.series.isEmpty() || !this.statuses.isEmpty(),
|
||||
@@ -131,5 +195,10 @@ public class RetryGatewayFilterFactory extends AbstractGatewayFilterFactory<Retr
|
||||
public List<HttpMethod> getMethods() {
|
||||
return methods;
|
||||
}
|
||||
|
||||
public List<Class<? extends Throwable>> getExceptions() {
|
||||
return exceptions;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,22 +89,34 @@ public class FilteringWebHandler implements WebHandler {
|
||||
|
||||
private static class DefaultGatewayFilterChain implements GatewayFilterChain {
|
||||
|
||||
private int index;
|
||||
private final int index;
|
||||
private final List<GatewayFilter> filters;
|
||||
|
||||
public DefaultGatewayFilterChain(List<GatewayFilter> filters) {
|
||||
this.filters = filters;
|
||||
this.index = 0;
|
||||
}
|
||||
|
||||
private DefaultGatewayFilterChain(DefaultGatewayFilterChain parent, int index) {
|
||||
this.filters = parent.getFilters();
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
public List<GatewayFilter> getFilters() {
|
||||
return filters;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange) {
|
||||
if (this.index < filters.size()) {
|
||||
GatewayFilter filter = filters.get(this.index++);
|
||||
return filter.filter(exchange, this);
|
||||
}
|
||||
else {
|
||||
return Mono.empty(); // complete
|
||||
}
|
||||
return Mono.defer(() -> {
|
||||
if (this.index < filters.size()) {
|
||||
GatewayFilter filter = filters.get(this.index);
|
||||
DefaultGatewayFilterChain chain = new DefaultGatewayFilterChain(this, this.index + 1);
|
||||
return filter.filter(exchange, chain);
|
||||
} else {
|
||||
return Mono.empty(); // complete
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
import reactor.retry.Repeat;
|
||||
import reactor.retry.Retry;
|
||||
|
||||
public class GatewayFilterSpec extends UriSpec {
|
||||
|
||||
@@ -219,17 +220,23 @@ public class GatewayFilterSpec extends UriSpec {
|
||||
*/
|
||||
public GatewayFilterSpec retry(int retries) {
|
||||
return filter(getBean(RetryGatewayFilterFactory.class)
|
||||
.apply(retry -> retry.setRetries(retries)));
|
||||
.apply(retryConfig -> retryConfig.setRetries(retries)));
|
||||
}
|
||||
|
||||
public GatewayFilterSpec retry(Consumer<RetryGatewayFilterFactory.Retry> retryConsumer) {
|
||||
public GatewayFilterSpec retry(Consumer<RetryGatewayFilterFactory.RetryConfig> retryConsumer) {
|
||||
return filter(getBean(RetryGatewayFilterFactory.class).apply(retryConsumer));
|
||||
}
|
||||
|
||||
public GatewayFilterSpec retry(Repeat<ServerWebExchange> repeat, Retry<ServerWebExchange> retry) {
|
||||
return filter(getBean(RetryGatewayFilterFactory.class).apply(repeat, retry));
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public GatewayFilterSpec retry(Repeat<ServerWebExchange> repeat) {
|
||||
return filter(getBean(RetryGatewayFilterFactory.class).apply(repeat));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public GatewayFilterSpec secureHeaders() {
|
||||
return filter(getBean(SecureHeadersGatewayFilterFactory.class).apply(c -> {}));
|
||||
}
|
||||
@@ -262,6 +269,7 @@ public class GatewayFilterSpec extends UriSpec {
|
||||
.apply(c -> c.setStatus(status)));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public GatewayFilterSpec saveSession() {
|
||||
return filter(getBean(SaveSessionGatewayFilterFactory.class).apply(c -> {}));
|
||||
}
|
||||
|
||||
@@ -17,9 +17,12 @@
|
||||
|
||||
package org.springframework.cloud.gateway.filter.factory;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import com.netflix.loadbalancer.Server;
|
||||
import com.netflix.loadbalancer.ServerList;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import org.junit.Test;
|
||||
@@ -29,9 +32,12 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.web.server.LocalServerPort;
|
||||
import org.springframework.cloud.gateway.route.RouteLocator;
|
||||
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
|
||||
import org.springframework.cloud.gateway.test.BaseWebClientTests;
|
||||
import org.springframework.cloud.netflix.ribbon.RibbonClient;
|
||||
import org.springframework.cloud.netflix.ribbon.StaticServerList;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -41,12 +47,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = RANDOM_PORT)
|
||||
@DirtiesContext
|
||||
public class RetryGatewayFilterFactoryIntegrationTests extends BaseWebClientTests {
|
||||
public class RetryConfigGatewayFilterFactoryIntegrationTests extends BaseWebClientTests {
|
||||
|
||||
@Test
|
||||
public void retryFilterGet() {
|
||||
@@ -77,10 +84,29 @@ public class RetryGatewayFilterFactoryIntegrationTests extends BaseWebClientTest
|
||||
// .expectBody(String.class).isEqualTo("3");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void retryFilterLoadBalancedWithMultipleServers() {
|
||||
String host = "www.retrywithloadbalancer.org";
|
||||
testClient.get()
|
||||
.uri("/get")
|
||||
.header(HttpHeaders.HOST, host)
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(Map.class)
|
||||
.consumeWith(res -> {
|
||||
Map body = res.getResponseBody();
|
||||
assertThat(body).isNotNull();
|
||||
Map<String, Object> headers = (Map<String, Object>) body.get("headers");
|
||||
assertThat(headers).containsEntry("X-Forwarded-Host", host);
|
||||
});
|
||||
}
|
||||
|
||||
@RestController
|
||||
@EnableAutoConfiguration
|
||||
@SpringBootConfiguration
|
||||
@Import(DefaultTestConfig.class)
|
||||
@RibbonClient(name = "badservice", configuration = TestBadRibbonConfig.class)
|
||||
public static class TestConfig {
|
||||
Log log = LogFactory.getLog(getClass());
|
||||
|
||||
@@ -108,8 +134,23 @@ public class RetryGatewayFilterFactoryIntegrationTests extends BaseWebClientTest
|
||||
.filters(f -> f.prefixPath("/httpbin")
|
||||
.retry(config -> config.setRetries(2)))
|
||||
.uri(uri))
|
||||
.route("retry_with_loadbalancer", r -> r.host("**.retrywithloadbalancer.org")
|
||||
.filters(f -> f.prefixPath("/httpbin")
|
||||
.retry(config -> config.setRetries(2)))
|
||||
.uri("lb://badservice"))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
protected static class TestBadRibbonConfig {
|
||||
|
||||
@LocalServerPort
|
||||
protected int port = 0;
|
||||
|
||||
@Bean
|
||||
public ServerList<Server> ribbonServerList() {
|
||||
return new StaticServerList<>(new Server("https", "localhost.domain.doesnot.exist", this.port), new Server("localhost", this.port));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,8 +17,18 @@
|
||||
|
||||
package org.springframework.cloud.gateway.test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.codec.multipart.FilePart;
|
||||
@@ -30,15 +40,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/httpbin")
|
||||
@@ -52,39 +53,40 @@ public class HttpBinCompatibleController {
|
||||
|
||||
@RequestMapping(path = "/headers", method = {
|
||||
RequestMethod.GET, RequestMethod.POST}, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Mono<Map<String, Object>> headers(ServerWebExchange exchange) {
|
||||
return getHeaders(exchange);
|
||||
public Map<String, Object> headers(ServerWebExchange exchange) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("headers", getHeaders(exchange));
|
||||
return result;
|
||||
}
|
||||
|
||||
@RequestMapping(path = "/delay/{sec}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Mono<Map<String, Object>> get(ServerWebExchange exchange, @PathVariable int sec) throws InterruptedException {
|
||||
int delay = Math.min(sec, 10);
|
||||
return get(exchange).delayElement(Duration.ofSeconds(delay));
|
||||
return Mono.just(get(exchange)).delayElement(Duration.ofSeconds(delay));
|
||||
}
|
||||
|
||||
@RequestMapping(path = "/anything/{anything}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Mono<Map<String, Object>> anything(ServerWebExchange exchange, @PathVariable(required = false) String anything) {
|
||||
public Map<String, Object> anything(ServerWebExchange exchange, @PathVariable(required = false) String anything) {
|
||||
return get(exchange);
|
||||
}
|
||||
|
||||
@RequestMapping(path = "/get", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Mono<Map<String, Object>> get(ServerWebExchange exchange) {
|
||||
return getHeaders(exchange).map(map -> {
|
||||
HashMap<String, Object> result = new HashMap<>(map);
|
||||
HashMap<String, String> params = new HashMap<>();
|
||||
exchange.getRequest().getQueryParams().forEach((name, values) -> {
|
||||
params.put(name, values.get(0));
|
||||
});
|
||||
result.put("args", params);
|
||||
return result;
|
||||
});
|
||||
public Map<String, Object> get(ServerWebExchange exchange) {
|
||||
HashMap<String, Object> result = new HashMap<>();
|
||||
HashMap<String, String> params = new HashMap<>();
|
||||
exchange.getRequest().getQueryParams().forEach((name, values) -> {
|
||||
params.put(name, values.get(0));
|
||||
});
|
||||
result.put("args", params);
|
||||
result.put("headers", getHeaders(exchange));
|
||||
return result;
|
||||
}
|
||||
|
||||
@RequestMapping(value = "/post", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Mono<Map<String, Object>> postFormData(@RequestBody Mono<MultiValueMap<String, Part>> parts) {
|
||||
// StringDecoder decoder = StringDecoder.allMimeTypes(true);
|
||||
return parts.flux().flatMap(map -> Flux.fromIterable(map.values()))
|
||||
.flatMap(map -> Flux.fromIterable(map))
|
||||
.flatMap(Flux::fromIterable)
|
||||
.filter(part -> part instanceof FilePart)
|
||||
.reduce(new HashMap<String, Object>(), (files, part) -> {
|
||||
MediaType contentType = part.headers().getContentType();
|
||||
@@ -104,6 +106,7 @@ public class HttpBinCompatibleController {
|
||||
public Mono<Map<String, Object>> post(ServerWebExchange exchange,
|
||||
@RequestBody(required = false) String body) throws IOException {
|
||||
HashMap<String, Object> ret = new HashMap<>();
|
||||
ret.put("headers", getHeaders(exchange));
|
||||
ret.put("data", body);
|
||||
HashMap<String, Object> form = new HashMap<>();
|
||||
ret.put("form", form);
|
||||
@@ -123,13 +126,7 @@ public class HttpBinCompatibleController {
|
||||
return ResponseEntity.status(status).body("Failed with "+status);
|
||||
}
|
||||
|
||||
private Mono<Map<String, Object>> getHeaders(ServerWebExchange exchange) {
|
||||
return Flux.fromIterable(exchange.getRequest().getHeaders().entrySet())
|
||||
.collectMap(entry -> entry.getKey(), entry -> entry.getValue().get(0))
|
||||
.map(map -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("headers", map);
|
||||
return result;
|
||||
});
|
||||
public Map<String, String> getHeaders(ServerWebExchange exchange) {
|
||||
return exchange.getRequest().getHeaders().toSingleValueMap();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user