diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index ffa7b422..8a8a6c71 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -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` diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java index e4362de6..cbffce7c 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java @@ -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 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; + } } } diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RateLimiter.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RateLimiter.java index 025d44f7..5f9a4b1d 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RateLimiter.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RateLimiter.java @@ -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 extends StatefulConfigurable { class Response { private final boolean allowed; private final long tokensRemaining; + private final Map headers; + public Response(boolean allowed, Map 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 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(); diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiter.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiter.java index 999b8d5c..79171854 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiter.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiter.java @@ -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 implements ApplicationContextAware { @Deprecated public static final String REPLENISH_RATE_KEY = "replenishRate"; @@ -35,6 +39,9 @@ public class RedisRateLimiter extends AbstractRateLimiter redisTemplate, RedisScript> script, Validator validator) { super(Config.class, CONFIGURATION_PROPERTY_NAME, validator); @@ -58,6 +78,38 @@ public class RedisRateLimiter extends AbstractRateLimiter getHeaders(Config config, Long tokensLeft) { + HashMap 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 getKeys(String id) { @@ -156,8 +214,8 @@ public class RedisRateLimiter extends AbstractRateLimiter 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 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)); + }); } diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/PrincipalNameKeyResolverIntegrationTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/PrincipalNameKeyResolverIntegrationTests.java index b7d0a723..dfc0099b 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/PrincipalNameKeyResolverIntegrationTests.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/PrincipalNameKeyResolverIntegrationTests.java @@ -144,7 +144,8 @@ public class PrincipalNameKeyResolverIntegrationTests { @Override public Mono 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 diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java index 24280bed..92e4114b 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java @@ -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();