From 8ba1c06183cc4b5db2ea725551161e7932e49284 Mon Sep 17 00:00:00 2001 From: dcutic Date: Wed, 2 Oct 2019 08:47:10 +0200 Subject: [PATCH] Adds support for rate limits bellow 1 req/s Adds requestedTokens configuration option. Previously, this was hardcoded to 1. Make the 'requested tokens' redis template argument configurable in order to allow defining rate limits lower than 1 req/s, e.g. 1 req/m. This is accomplished by setting: - replenishRate = requestRate - burstRate = requestRate * timeSpanInSeconds - requestedTokens = timeSpanInSeconds For 1 req/m this would be accomplished by: - replenishRate = 1 - burstRate = 60 - requestedTokens = 60 fixes gh-1327 --- .../main/asciidoc/spring-cloud-gateway.adoc | 10 +- .../filter/ratelimit/RedisRateLimiter.java | 61 ++++++++- .../RedisRateLimiterConfigTests.java | 23 ++-- ...isRateLimiterDefaultFilterConfigTests.java | 10 +- .../ratelimit/RedisRateLimiterTests.java | 123 +++++++++++++----- .../ratelimit/RedisRateLimiterUnitTests.java | 113 ++++++++++++++++ .../application-redis-rate-limiter-config.yml | 9 ++ ...tion-redis-rate-limiter-default-config.yml | 1 + 8 files changed, 296 insertions(+), 54 deletions(-) create mode 100644 spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterUnitTests.java diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 3c5d20da..10074746 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -994,18 +994,23 @@ It requires the use of the `spring-boot-starter-data-redis-reactive` Spring Boot 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 you want a user to be allowed to do, without any dropped requests. +The `redis-rate-limiter.replenishRate` property is how many requests per second you want a user to be allowed to do, without any dropped requests. This is the rate at which 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. +The `redis-rate-limiter.burstCapacity` property 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 blocks all requests. +The `redis-rate-limiter.requestedTokens` property is how many tokens a request costs. +This is the number of tokens taken from the bucket for each request and defaults to `1`. + 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 two consecutive bursts will result in dropped requests (`HTTP 429 - Too Many Requests`). The following listing configures a `redis-rate-limiter`: +Rate limits bellow `1 request/s` are accomplished by setting `replenishRate` to the wanted number of requests, `requestedTokens` to the timespan in seconds and `burstCapacity` to the product of `replenishRate` and `requestedTokens`, e.g. setting `replenishRate=1`, `requestedTokens=60` and `burstCapacity=60` will result in a limit of `1 request/min`. + .application.yml ==== [source,yaml] @@ -1021,6 +1026,7 @@ spring: args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 + redis-rate-limiter.requestedTokens: 1 ---- ==== 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 522f70ed..85b4a623 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 @@ -38,6 +38,7 @@ import org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator; import org.springframework.cloud.gateway.support.ConfigurationService; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; +import org.springframework.core.style.ToStringCreator; import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.validation.Validator; @@ -49,6 +50,7 @@ import org.springframework.validation.annotation.Validated; * * @author Spencer Gibb * @author Ronny Bräunlich + * @author Denis Cutic */ @ConfigurationProperties("spring.cloud.gateway.redis-rate-limiter") public class RedisRateLimiter extends AbstractRateLimiter @@ -87,10 +89,15 @@ public class RedisRateLimiter extends AbstractRateLimiter> script, ConfigurationService configurationService) { super(Config.class, CONFIGURATION_PROPERTY_NAME, configurationService); @@ -141,7 +151,7 @@ public class RedisRateLimiter extends AbstractRateLimiter getKeys(String id) { // use `{}` around keys to use Redis Key hash tags // this allows for using redis cluster @@ -194,6 +217,14 @@ public class RedisRateLimiter extends AbstractRateLimiter keys = getKeys(id); // The arguments to the LUA script. time() returns unixtime in seconds. List scriptArgs = Arrays.asList(replenishRate + "", - burstCapacity + "", Instant.now().getEpochSecond() + "", "1"); + burstCapacity + "", Instant.now().getEpochSecond() + "", + requestedTokens + ""); // allowed, tokens_left = redis.eval(SCRIPT, keys, args) Flux> flux = this.redisTemplate.execute(this.script, keys, scriptArgs); @@ -298,6 +333,8 @@ public class RedisRateLimiter extends AbstractRateLimiter r.getId().equals(key)).next() .block(); @@ -100,7 +106,8 @@ public class RedisRateLimiterConfigTests { return builder.routes().route("custom_redis_rate_limiter", r -> r.path("/custom").filters(f -> f.requestRateLimiter() .rateLimiter(RedisRateLimiter.class, - rl -> rl.setBurstCapacity(40).setReplenishRate(20)) + rl -> rl.setBurstCapacity(40).setReplenishRate(20) + .setRequestedTokens(10)) .and()).uri("http://localhost")) .route("alt_custom_redis_rate_limiter", r -> r.path("/custom") @@ -113,7 +120,7 @@ public class RedisRateLimiterConfigTests { @Bean public RedisRateLimiter myRateLimiter() { - return new RedisRateLimiter(30, 60); + return new RedisRateLimiter(30, 60, 20); } } diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterDefaultFilterConfigTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterDefaultFilterConfigTests.java index e1e90a7d..4c0f3506 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterDefaultFilterConfigTests.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterDefaultFilterConfigTests.java @@ -34,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat; /** * @author Spencer Gibb + * @author Denis Cutic */ @RunWith(SpringRunner.class) @SpringBootTest @@ -49,22 +50,23 @@ public class RedisRateLimiterDefaultFilterConfigTests { @Before public void init() { - routeLocator.getRoutes().collectList().block(); // prime routes since getRoutes() - // no longer blocks + // prime routes since getRoutes() no longer blocks + routeLocator.getRoutes().collectList().block(); } @Test public void redisRateConfiguredFromEnvironmentDefaultFilters() { String routeId = "redis_rate_limiter_config_default_test"; RedisRateLimiter.Config config = rateLimiter.loadConfiguration(routeId); - assertConfigAndRoute(routeId, 70, 80, config); + assertConfigAndRoute(routeId, 70, 80, 10, config); } private void assertConfigAndRoute(String key, int replenishRate, int burstCapacity, - RedisRateLimiter.Config config) { + int requestedTokens, RedisRateLimiter.Config config) { assertThat(config).isNotNull(); assertThat(config.getReplenishRate()).isEqualTo(replenishRate); assertThat(config.getBurstCapacity()).isEqualTo(burstCapacity); + assertThat(config.getRequestedTokens()).isEqualTo(requestedTokens); Route route = routeLocator.getRoutes().filter(r -> r.getId().equals(key)).next() .block(); 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 425e9a2c..7636a858 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 @@ -18,6 +18,8 @@ package org.springframework.cloud.gateway.filter.ratelimit; import java.util.UUID; +import org.junit.After; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -45,6 +47,7 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen * * @author Spencer Gibb * @author Ronny Bräunlich + * @author Denis Cutic */ @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = RANDOM_PORT) @@ -57,52 +60,48 @@ public class RedisRateLimiterTests extends BaseWebClientTests { @Autowired private RedisRateLimiter rateLimiter; + @Before + public void setUp() throws Exception { + assumeThat("Ignore on Circle", System.getenv("CIRCLECI"), is(nullValue())); + } + + @After + public void tearDown() throws Exception { + rateLimiter.setIncludeHeaders(true); + } + @Test public void redisRateLimiterWorks() throws Exception { - assumeThat("Ignore on Circle", System.getenv("CIRCLECI"), is(nullValue())); - String id = UUID.randomUUID().toString(); int replenishRate = 10; int burstCapacity = 2 * replenishRate; + int requestedTokens = 1; String routeId = "myroute"; - rateLimiter.getConfig().put(routeId, new RedisRateLimiter.Config() - .setBurstCapacity(burstCapacity).setReplenishRate(replenishRate)); + rateLimiter.getConfig().put(routeId, + new RedisRateLimiter.Config().setBurstCapacity(burstCapacity) + .setReplenishRate(replenishRate) + .setRequestedTokens(requestedTokens)); - // Bursts work - 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)); - } + checkLimitEnforced(id, replenishRate, burstCapacity, requestedTokens, routeId); + } - Response response = rateLimiter.isAllowed(routeId, id).block(); - if (response.isAllowed()) { // TODO: sometimes there is an off by one error - response = rateLimiter.isAllowed(routeId, id).block(); - } - assertThat(response.isAllowed()).as("Burst # %s is not allowed", burstCapacity) - .isFalse(); + @Test + public void redisRateLimiterWorksForLowRates() throws Exception { + String id = UUID.randomUUID().toString(); - Thread.sleep(1000); + int replenishRate = 1; + int burstCapacity = 3; + int requestedTokens = 3; - // # After the burst is done, check the steady state - for (int i = 0; i < replenishRate; i++) { - response = rateLimiter.isAllowed(routeId, id).block(); - assertThat(response.isAllowed()).as("steady state # %s is allowed", i) - .isTrue(); - } + String routeId = "low_rate_route"; + rateLimiter.getConfig().put(routeId, + new RedisRateLimiter.Config().setBurstCapacity(burstCapacity) + .setReplenishRate(replenishRate) + .setRequestedTokens(requestedTokens)); - response = rateLimiter.isAllowed(routeId, id).block(); - assertThat(response.isAllowed()).as("steady state # %s is allowed", replenishRate) - .isFalse(); + checkLimitEnforced(id, replenishRate, burstCapacity, requestedTokens, routeId); } @Test @@ -113,8 +112,6 @@ public class RedisRateLimiterTests extends BaseWebClientTests { @Test public void redisRateLimiterDoesNotSendHeadersIfDeactivated() throws Exception { - assumeThat("Ignore on Circle", System.getenv("CIRCLECI"), is(nullValue())); - String id = UUID.randomUUID().toString(); String routeId = "myroute"; @@ -128,6 +125,62 @@ public class RedisRateLimiterTests extends BaseWebClientTests { .doesNotContainKey(RedisRateLimiter.REPLENISH_RATE_HEADER); assertThat(response.getHeaders()) .doesNotContainKey(RedisRateLimiter.BURST_CAPACITY_HEADER); + assertThat(response.getHeaders()) + .doesNotContainKey(RedisRateLimiter.REQUESTED_TOKENS_HEADER); + } + + private void checkLimitEnforced(String id, int replenishRate, int burstCapacity, + int requestedTokens, String routeId) throws InterruptedException { + // Bursts work + simulateBurst(id, replenishRate, burstCapacity, requestedTokens, routeId); + + checkLimitReached(id, burstCapacity, routeId); + + Thread.sleep(Math.max(1, requestedTokens / replenishRate) * 1000); + + // # After the burst is done, check the steady state + checkSteadyState(id, replenishRate, routeId); + } + + private void simulateBurst(String id, int replenishRate, int burstCapacity, + int requestedTokens, String routeId) { + for (int i = 0; i < burstCapacity / requestedTokens; 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)); + assertThat(response.getHeaders()).containsEntry( + RedisRateLimiter.REQUESTED_TOKENS_HEADER, + String.valueOf(requestedTokens)); + } + } + + private void checkLimitReached(String id, int burstCapacity, String routeId) { + Response response = rateLimiter.isAllowed(routeId, id).block(); + if (response.isAllowed()) { // TODO: sometimes there is an off by one error + response = rateLimiter.isAllowed(routeId, id).block(); + } + assertThat(response.isAllowed()).as("Burst # %s is not allowed", burstCapacity) + .isFalse(); + } + + private void checkSteadyState(String id, int replenishRate, String routeId) { + Response response; + for (int i = 0; i < replenishRate; i++) { + response = rateLimiter.isAllowed(routeId, id).block(); + assertThat(response.isAllowed()).as("steady state # %s is allowed", i) + .isTrue(); + } + + response = rateLimiter.isAllowed(routeId, id).block(); + assertThat(response.isAllowed()).as("steady state # %s is allowed", replenishRate) + .isFalse(); } @EnableAutoConfiguration diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterUnitTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterUnitTests.java new file mode 100644 index 00000000..cdc0b203 --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterUnitTests.java @@ -0,0 +1,113 @@ +/* + * Copyright 2013-2019 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 + * + * https://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.ratelimit; + +import io.lettuce.core.RedisException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.support.ConfigurationService; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.data.MapEntry.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +/** + * @author Denis Cutic + */ +@RunWith(MockitoJUnitRunner.class) +public class RedisRateLimiterUnitTests { + + private static final int DEFAULT_REPLENISH_RATE = 1; + + private static final int DEFAULT_BURST_CAPACITY = 1; + + public static final String ROUTE_ID = "routeId"; + + public static final String REQUEST_ID = "id"; + + public static final String[] CONFIGURATION_SERVICE_BEANS = new String[0]; + + public static final RedisException REDIS_EXCEPTION = new RedisException( + "Mocked problem"); + + @Mock + private ApplicationContext applicationContext; + + @Mock + private ReactiveStringRedisTemplate redisTemplate; + + private RedisRateLimiter redisRateLimiter; + + @Before + public void setUp() { + when(applicationContext.getBean(ReactiveStringRedisTemplate.class)) + .thenReturn(redisTemplate); + when(applicationContext.getBeanNamesForType(ConfigurationService.class)) + .thenReturn(CONFIGURATION_SERVICE_BEANS); + redisRateLimiter = new RedisRateLimiter(DEFAULT_REPLENISH_RATE, + DEFAULT_BURST_CAPACITY); + } + + @After + public void tearDown() { + Mockito.reset(applicationContext); + } + + @Test(expected = IllegalStateException.class) + public void shouldThrowWhenNotInitialized() { + redisRateLimiter.isAllowed(ROUTE_ID, REQUEST_ID); + } + + @Test + public void shouldAllowRequestWhenRedisIssueOccurs() { + when(redisTemplate.execute(any(), anyList(), anyList())) + .thenThrow(REDIS_EXCEPTION); + redisRateLimiter.setApplicationContext(applicationContext); + Mono response = redisRateLimiter.isAllowed(ROUTE_ID, + REQUEST_ID); + assertThat(response.block()).extracting(RateLimiter.Response::isAllowed) + .isEqualTo(true); + } + + @Test + public void shouldReturnHeadersWhenRedisIssueOccurs() { + when(redisTemplate.execute(any(), anyList(), anyList())) + .thenThrow(REDIS_EXCEPTION); + redisRateLimiter.setApplicationContext(applicationContext); + Mono response = redisRateLimiter.isAllowed(ROUTE_ID, + REQUEST_ID); + assertThat(response.block().getHeaders()).containsOnly( + entry(redisRateLimiter.getRemainingHeader(), "-1"), + entry(redisRateLimiter.getBurstCapacityHeader(), + DEFAULT_BURST_CAPACITY + ""), + entry(redisRateLimiter.getReplenishRateHeader(), + DEFAULT_REPLENISH_RATE + ""), + entry(redisRateLimiter.getRequestedTokensHeader(), "1")); + } + +} diff --git a/spring-cloud-gateway-core/src/test/resources/application-redis-rate-limiter-config.yml b/spring-cloud-gateway-core/src/test/resources/application-redis-rate-limiter-config.yml index 85ac957d..a6c5726d 100644 --- a/spring-cloud-gateway-core/src/test/resources/application-redis-rate-limiter-config.yml +++ b/spring-cloud-gateway-core/src/test/resources/application-redis-rate-limiter-config.yml @@ -14,4 +14,13 @@ spring: redis-rate-limiter: replenish-rate: 10 burst-capacity: 20 + - id: redis_rate_limiter_minimal_config_test + uri: ${test.uri} + predicates: + - Path=/ + filters: + - name: RequestRateLimiter + args: + redis-rate-limiter: + replenish-rate: 2 diff --git a/spring-cloud-gateway-core/src/test/resources/application-redis-rate-limiter-default-config.yml b/spring-cloud-gateway-core/src/test/resources/application-redis-rate-limiter-default-config.yml index e1ba4079..94ae146a 100644 --- a/spring-cloud-gateway-core/src/test/resources/application-redis-rate-limiter-default-config.yml +++ b/spring-cloud-gateway-core/src/test/resources/application-redis-rate-limiter-default-config.yml @@ -7,6 +7,7 @@ spring: redis-rate-limiter: replenish-rate: 70 burst-capacity: 80 + requested-tokens: 10 routes: # ===================================== - id: redis_rate_limiter_config_default_test