Merge branch 'master' of github.com:spring-cloud/spring-cloud-gateway
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user