Merge branch 'master' of github.com:spring-cloud/spring-cloud-gateway

This commit is contained in:
Ryan Baxter
2018-06-14 11:06:08 -04:00
7 changed files with 192 additions and 22 deletions

View File

@@ -448,11 +448,9 @@ This will prefix `/mypath` to the path of all matching requests. So a request to
=== RequestRateLimiter GatewayFilter Factory
The RequestRateLimiter GatewayFilter Factory takes three parameters: `replenishRate`, `burstCapacity` & `keyResolverName`.
The RequestRateLimiter GatewayFilter Factory is uses a `RateLimiter` implementation to determine if the current request is allowed to proceed. If it is not, a status of `HTTP 429 - Too Many Requests` (by default) is returned.
`replenishRate` is how many requests per second do you want a user to be allowed to do.
`burstCapacity` TODO: document burst capacity
This filter takes an optional `keyResolver` parameter and parameters specific to the rate limiter (see below).
`keyResolver` is a bean that implements the `KeyResolver` interface. In configuration, reference the bean by name using SpEL. `#{@myKeyResolver}` is a SpEL expression referencing a bean with the name `myKeyResolver`.
@@ -466,8 +464,27 @@ public interface KeyResolver {
The `KeyResolver` interface allows pluggable strategies to derive the key for limiting requests. In future milestones, there will be some `KeyResolver` implementations.
The default implementation of `KeyResolver` is the `PrincipalNameKeyResolver` which retrieves the `Principal` from the `ServerWebExchange` and calls `Principal.getName()`.
NOTE: The RequestRateLimiter is not configurable via the "shortcut" notation. The example below is __invalid__
.application.properties
----
# INVALID SHORTCUT CONFIGURATION
spring.cloud.gateway.routes[0].filters[0]=RequestRateLimiter=2, 2, #{@userkeyresolver}
----
==== Redis RateLimiter
The redis implementation is based off of work done at https://stripe.com/blog/rate-limiters[Stripe]. It requires the use of the `spring-boot-starter-data-redis-reactive` Spring Boot starter.
The algorithm used is the https://en.wikipedia.org/wiki/Token_bucket[Token Bucket Algorithm].
The `redis-rate-limiter.replenishRate` is how many requests per second do you want a user to be allowed to do, without any dropped requests. This is the rate that the token bucket is filled.
The `redis-rate-limiter.burstCapacity` is the maximum number of requests a user is allowed to do in a single second. This is the number of tokens the token bucket can hold. Setting this value to zero will block all requests.
A steady rate is accomplished by setting the same value in `replenishRate` and `burstCapacity`. Temporary bursts can be allowed by setting `burstCapacity` higher than `replenishRate`. In this case, the rate limiter needs to be allowed some time between bursts (according to `replenishRate`), as 2 consecutive bursts will result in dropped requests (`HTTP 429 - Too Many Requests`).
.application.yml
[source,yaml]
@@ -479,7 +496,11 @@ spring:
- id: requestratelimiter_route
uri: http://example.org
filters:
- RequestRateLimiter=10, 20, #{@userKeyResolver}
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
----
.Config.java
@@ -491,7 +512,26 @@ KeyResolver userKeyResolver() {
}
----
This defines a request rate limit of 10 per user. The `KeyResolver` is a simple one that gets the `user` request parameter (note: this is not recommended for production).
This defines a request rate limit of 10 per user. A burst of 20 is allowed, but the next second only 10 requests will be available. The `KeyResolver` is a simple one that gets the `user` request parameter (note: this is not recommended for production).
A rate limiter can also be defined as a bean implementing the `RateLimiter` interface. In configuration, reference the bean by name using SpEL. `#{@myRateLimiter}` is a SpEL expression referencing a bean with the name `myRateLimiter`.
.application.yml
[source,yaml]
----
spring:
cloud:
gateway:
routes:
- id: requestratelimiter_route
uri: http://example.org
filters:
- name: RequestRateLimiter
args:
rate-limiter: "#{@myRateLimiter}"
key-resolver: "#{@userKeyResolver}"
----
=== RedirectTo GatewayFilter Factory
The RedirectTo GatewayFilter Factory takes a `status` and a `url` parameter. The status should be a 300 series redirect http code, such as 301. The url should be a valid url. This will be the value of the `Location` header.
@@ -786,6 +826,27 @@ The Websocket Routing Filter runs if the url located in the `ServerWebExchangeUt
Websockets may be load-balanced by prefixing the URI with `lb`, such as `lb:ws://serviceid`.
NOTE: If you are using https://github.com/sockjs[SockJS] as a fallback over normal http, you should configure a normal HTTP route as well as the Websocket Route.
.application.yml
[source,yaml]
----
spring:
cloud:
gateway:
routes:
# SockJS route
- id: websocket_sockjs_route
uri: http://localhost:3001
predicates:
- Path=/websocket/info/**
# Normwal Websocket route
- id: websocket_route
uri: ws://localhost:3001
predicates:
- Path=/websocket/**
----
=== Making An Exchange As Routed
After the Gateway has routed a `ServerWebExchange` it will mark that exchange as "routed" by adding `gatewayAlreadyRouted`

View File

@@ -24,6 +24,8 @@ import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.http.HttpStatus;
import java.util.Map;
/**
* User Request Rate Limiter filter. See https://stripe.com/blog/rate-limiters and
*/
@@ -61,12 +63,16 @@ public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilte
return resolver.resolve(exchange).flatMap(key ->
// TODO: if key is empty?
limiter.isAllowed(route.getId(), key).flatMap(response -> {
// TODO: set some headers for rate, tokens left
for (Map.Entry<String, String> header : response.getHeaders().entrySet()) {
exchange.getResponse().getHeaders().add(header.getKey(), header.getValue());
}
if (response.isAllowed()) {
return chain.filter(exchange);
}
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
exchange.getResponse().setStatusCode(config.getStatusCode());
return exchange.getResponse().setComplete();
}));
};
@@ -75,7 +81,7 @@ public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilte
public static class Config {
private KeyResolver keyResolver;
private RateLimiter rateLimiter;
private HttpStatus statusCode = HttpStatus.TOO_MANY_REQUESTS;
public KeyResolver getKeyResolver() {
return keyResolver;
@@ -93,6 +99,15 @@ public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilte
this.rateLimiter = rateLimiter;
return this;
}
public HttpStatus getStatusCode() {
return statusCode;
}
public Config setStatusCode(HttpStatus statusCode) {
this.statusCode = statusCode;
return this;
}
}
}

View File

@@ -2,8 +2,12 @@ package org.springframework.cloud.gateway.filter.ratelimit;
import org.springframework.cloud.gateway.support.StatefulConfigurable;
import org.springframework.util.Assert;
import reactor.core.publisher.Mono;
import java.util.Collections;
import java.util.Map;
/**
* @author Spencer Gibb
*/
@@ -14,24 +18,40 @@ public interface RateLimiter<C> extends StatefulConfigurable<C> {
class Response {
private final boolean allowed;
private final long tokensRemaining;
private final Map<String, String> headers;
public Response(boolean allowed, Map<String, String> headers) {
this.allowed = allowed;
this.tokensRemaining = -1;
Assert.notNull(headers, "headers may not be null");
this.headers = headers;
}
@Deprecated
public Response(boolean allowed, long tokensRemaining) {
this.allowed = allowed;
this.tokensRemaining = tokensRemaining;
this.headers = Collections.emptyMap();
}
public boolean isAllowed() {
return allowed;
}
@Deprecated
public long getTokensRemaining() {
return tokensRemaining;
}
public Map<String, String> getHeaders() {
return Collections.unmodifiableMap(headers);
}
@Override
public String toString() {
final StringBuffer sb = new StringBuffer("Response{");
sb.append("allowed=").append(allowed);
sb.append(", headers=").append(headers);
sb.append(", tokensRemaining=").append(tokensRemaining);
sb.append('}');
return sb.toString();

View File

@@ -3,6 +3,7 @@ package org.springframework.cloud.gateway.filter.ratelimit;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -10,10 +11,12 @@ import javax.validation.constraints.Min;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jetbrains.annotations.NotNull;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.beans.BeansException;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
@@ -27,6 +30,7 @@ import org.springframework.validation.annotation.Validated;
*
* @author Spencer Gibb
*/
@ConfigurationProperties("spring.cloud.gateway.redis-rate-limiter")
public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Config> implements ApplicationContextAware {
@Deprecated
public static final String REPLENISH_RATE_KEY = "replenishRate";
@@ -35,6 +39,9 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
public static final String CONFIGURATION_PROPERTY_NAME = "redis-rate-limiter";
public static final String REDIS_SCRIPT_NAME = "redisRequestRateLimiterScript";
public static final String REMAINING_HEADER = "X-RateLimit-Remaining";
public static final String REPLENISH_RATE_HEADER = "X-RateLimit-Replenish-Rate";
public static final String BURST_CAPACITY_HEADER = "X-RateLimit-Burst-Capacity";
private Log log = LogFactory.getLog(getClass());
@@ -43,6 +50,19 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
private AtomicBoolean initialized = new AtomicBoolean(false);
private Config defaultConfig;
// configuration properties
/** Whether or not to include headers containing rate limiter information, defaults to true. */
private boolean includeHeaders = true;
/** The name of the header that returns number of remaining requests during the current second. */
private String remainingHeader = REMAINING_HEADER;
/** The name of the header that returns the replenish rate configuration. */
private String replenishRateHeader = REPLENISH_RATE_HEADER;
/** The name of the header that returns the burst capacity configuration. */
private String burstCapacityHeader = BURST_CAPACITY_HEADER;
public RedisRateLimiter(ReactiveRedisTemplate<String, String> redisTemplate,
RedisScript<List<Long>> script, Validator validator) {
super(Config.class, CONFIGURATION_PROPERTY_NAME, validator);
@@ -58,6 +78,38 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
.setBurstCapacity(defaultBurstCapacity);
}
public boolean isIncludeHeaders() {
return includeHeaders;
}
public void setIncludeHeaders(boolean includeHeaders) {
this.includeHeaders = includeHeaders;
}
public String getRemainingHeader() {
return remainingHeader;
}
public void setRemainingHeader(String remainingHeader) {
this.remainingHeader = remainingHeader;
}
public String getReplenishRateHeader() {
return replenishRateHeader;
}
public void setReplenishRateHeader(String replenishRateHeader) {
this.replenishRateHeader = replenishRateHeader;
}
public String getBurstCapacityHeader() {
return burstCapacityHeader;
}
public void setBurstCapacityHeader(String burstCapacityHeader) {
this.burstCapacityHeader = burstCapacityHeader;
}
@Override
@SuppressWarnings("unchecked")
public void setApplicationContext(ApplicationContext context) throws BeansException {
@@ -86,13 +138,10 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
throw new IllegalStateException("RedisRateLimiter is not initialized");
}
Config routeConfig = getConfig().get(routeId);
Config routeConfig = getConfig().getOrDefault(routeId, defaultConfig);
if (routeConfig == null) {
if (defaultConfig == null) {
throw new IllegalArgumentException("No Configuration found for route " + routeId);
}
routeConfig = defaultConfig;
throw new IllegalArgumentException("No Configuration found for route " + routeId);
}
// How many requests per second do you want a user to be allowed to do?
@@ -119,7 +168,7 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
boolean allowed = results.get(0) == 1L;
Long tokensLeft = results.get(1);
Response response = new Response(allowed, tokensLeft);
Response response = new Response(allowed, getHeaders(routeConfig, tokensLeft));
if (log.isDebugEnabled()) {
log.debug("response: " + response);
@@ -135,7 +184,16 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
*/
log.error("Error determining if user allowed from redis", e);
}
return Mono.just(new Response(true, -1));
return Mono.just(new Response(true, getHeaders(routeConfig, -1L)));
}
@NotNull
public HashMap<String, String> getHeaders(Config config, Long tokensLeft) {
HashMap<String, String> headers = new HashMap<>();
headers.put(this.remainingHeader, tokensLeft.toString());
headers.put(this.replenishRateHeader, String.valueOf(config.getReplenishRate()));
headers.put(this.burstCapacityHeader, String.valueOf(config.getBurstCapacity()));
return headers;
}
static List<String> getKeys(String id) {
@@ -156,8 +214,8 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
@Min(1)
private int replenishRate;
@Min(0)
private int burstCapacity = 0;
@Min(1)
private int burstCapacity = 1;
public int getReplenishRate() {
return replenishRate;

View File

@@ -31,6 +31,9 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen
import reactor.core.publisher.Mono;
import java.util.Collections;
import java.util.Map;
/**
* see https://gist.github.com/ptarjan/e38f45f2dfe601419ca3af937fff574d#file-1-check_request_rate_limiter-rb-L36-L62
* @author Spencer Gibb
@@ -69,8 +72,12 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe
private void assertFilterFactory(KeyResolver keyResolver, String key, boolean allowed, HttpStatus expectedStatus) {
String tokensRemaining = allowed ? "1" : "0";
Map<String, String> headers = Collections.singletonMap("X-Tokens-Remaining", tokensRemaining);
when(rateLimiter.isAllowed("myroute", key))
.thenReturn(Mono.just(new Response(allowed, 1)));
.thenReturn(Mono.just(new Response(allowed, headers)));
MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
@@ -85,8 +92,11 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe
GatewayFilter filter = factory.apply(config -> config.setKeyResolver(keyResolver));
Mono<Void> response = filter.filter(exchange, this.filterChain);
response.subscribe(aVoid -> assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(expectedStatus));
response.subscribe(aVoid -> {
assertThat(exchange.getResponse().getStatusCode()).isEqualTo(expectedStatus);
assertThat(exchange.getResponse().getHeaders()).
containsEntry("X-Tokens-Remaining", Collections.singletonList(tokensRemaining));
});
}

View File

@@ -144,7 +144,8 @@ public class PrincipalNameKeyResolverIntegrationTests {
@Override
public Mono<Response> isAllowed(String routeId, String id) {
return Mono.just(new RateLimiter.Response(true, Long.MAX_VALUE));
return Mono.just(new RateLimiter.Response(true,
Collections.singletonMap("X-Value", "5000000")));
}
@Override

View File

@@ -51,6 +51,11 @@ public class RedisRateLimiterTests extends BaseWebClientTests {
for (int i = 0; i < burstCapacity; i++) {
Response response = rateLimiter.isAllowed(routeId, id).block();
assertThat(response.isAllowed()).as("Burst # %s is allowed", i).isTrue();
assertThat(response.getHeaders()).containsKey(RedisRateLimiter.REMAINING_HEADER);
assertThat(response.getHeaders()).
containsEntry(RedisRateLimiter.REPLENISH_RATE_HEADER, String.valueOf(replenishRate));
assertThat(response.getHeaders()).
containsEntry(RedisRateLimiter.BURST_CAPACITY_HEADER, String.valueOf(burstCapacity));
}
Response response = rateLimiter.isAllowed(routeId, id).block();