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
This commit is contained in:
dcutic
2019-10-02 08:47:10 +02:00
committed by Spencer Gibb
parent fc7ed5b0a6
commit 8ba1c06183
8 changed files with 296 additions and 54 deletions

View File

@@ -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
----
====

View File

@@ -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<RedisRateLimiter.Config>
@@ -87,10 +89,15 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
public static final String REPLENISH_RATE_HEADER = "X-RateLimit-Replenish-Rate";
/**
* Burst Capacity Header name.
* Burst Capacity header name.
*/
public static final String BURST_CAPACITY_HEADER = "X-RateLimit-Burst-Capacity";
/**
* Requested Tokens header name.
*/
public static final String REQUESTED_TOKENS_HEADER = "X-RateLimit-Requested-Tokens";
private Log log = LogFactory.getLog(getClass());
private ReactiveStringRedisTemplate redisTemplate;
@@ -120,6 +127,9 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
/** The name of the header that returns the burst capacity configuration. */
private String burstCapacityHeader = BURST_CAPACITY_HEADER;
/** The name of the header that returns the requested tokens configuration. */
private String requestedTokensHeader = REQUESTED_TOKENS_HEADER;
public RedisRateLimiter(ReactiveStringRedisTemplate redisTemplate,
RedisScript<List<Long>> script, ConfigurationService configurationService) {
super(Config.class, CONFIGURATION_PROPERTY_NAME, configurationService);
@@ -141,7 +151,7 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
* This creates an instance with default static configuration, useful in Java DSL.
* @param defaultReplenishRate how many tokens per second in token-bucket algorithm.
* @param defaultBurstCapacity how many tokens the bucket can hold in token-bucket
* alogritm.
* algorithm.
*/
public RedisRateLimiter(int defaultReplenishRate, int defaultBurstCapacity) {
super(Config.class, CONFIGURATION_PROPERTY_NAME, (ConfigurationService) null);
@@ -149,6 +159,19 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
.setBurstCapacity(defaultBurstCapacity);
}
/**
* This creates an instance with default static configuration, useful in Java DSL.
* @param defaultReplenishRate how many tokens per second in token-bucket algorithm.
* @param defaultBurstCapacity how many tokens the bucket can hold in token-bucket
* algorithm.
* @param defaultRequestedTokens how many tokens are requested per request.
*/
public RedisRateLimiter(int defaultReplenishRate, int defaultBurstCapacity,
int defaultRequestedTokens) {
this(defaultReplenishRate, defaultBurstCapacity);
this.defaultConfig.setRequestedTokens(defaultRequestedTokens);
}
static List<String> 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<RedisRateLimiter.Confi
this.burstCapacityHeader = burstCapacityHeader;
}
public String getRequestedTokensHeader() {
return requestedTokensHeader;
}
public void setRequestedTokensHeader(String requestedTokensHeader) {
this.requestedTokensHeader = requestedTokensHeader;
}
/**
* Used when setting default configuration in constructor.
* @param context the ApplicationContext object to be used by this object
@@ -237,12 +268,16 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
// How much bursting do you want to allow?
int burstCapacity = routeConfig.getBurstCapacity();
// How many tokens are requested per request?
int requestedTokens = routeConfig.getRequestedTokens();
try {
List<String> keys = getKeys(id);
// The arguments to the LUA script. time() returns unixtime in seconds.
List<String> scriptArgs = Arrays.asList(replenishRate + "",
burstCapacity + "", Instant.now().getEpochSecond() + "", "1");
burstCapacity + "", Instant.now().getEpochSecond() + "",
requestedTokens + "");
// allowed, tokens_left = redis.eval(SCRIPT, keys, args)
Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys,
scriptArgs);
@@ -298,6 +333,8 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
String.valueOf(config.getReplenishRate()));
headers.put(this.burstCapacityHeader,
String.valueOf(config.getBurstCapacity()));
headers.put(this.requestedTokensHeader,
String.valueOf(config.getRequestedTokens()));
}
return headers;
}
@@ -311,6 +348,9 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
@Min(1)
private int burstCapacity = 1;
@Min(1)
private int requestedTokens = 1;
public int getReplenishRate() {
return replenishRate;
}
@@ -329,10 +369,21 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
return this;
}
public int getRequestedTokens() {
return requestedTokens;
}
public Config setRequestedTokens(int requestedTokens) {
this.requestedTokens = requestedTokens;
return this;
}
@Override
public String toString() {
return "Config{" + "replenishRate=" + replenishRate + ", burstCapacity="
+ burstCapacity + '}';
return new ToStringCreator(this).append("replenishRate", replenishRate)
.append("burstCapacity", burstCapacity)
.append("requestedTokens", requestedTokens).toString();
}
}

View File

@@ -51,27 +51,32 @@ public class RedisRateLimiterConfigTests {
@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 redisRateConfiguredFromEnvironment() {
assertFilter("redis_rate_limiter_config_test", 10, 20, false);
assertFilter("redis_rate_limiter_config_test", 10, 20, 1, false);
}
@Test
public void redisRateConfiguredFromEnvironmentMinimal() {
assertFilter("redis_rate_limiter_minimal_config_test", 2, 1, 1, false);
}
@Test
public void redisRateConfiguredFromJavaAPI() {
assertFilter("custom_redis_rate_limiter", 20, 40, false);
assertFilter("custom_redis_rate_limiter", 20, 40, 10, false);
}
@Test
public void redisRateConfiguredFromJavaAPIDirectBean() {
assertFilter("alt_custom_redis_rate_limiter", 30, 60, true);
assertFilter("alt_custom_redis_rate_limiter", 30, 60, 20, true);
}
private void assertFilter(String key, int replenishRate, int burstCapacity,
boolean useDefaultConfig) {
int requestedTokens, boolean useDefaultConfig) {
RedisRateLimiter.Config config;
if (useDefaultConfig) {
@@ -84,6 +89,7 @@ public class RedisRateLimiterConfigTests {
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();
@@ -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);
}
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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<RateLimiter.Response> 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<RateLimiter.Response> 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"));
}
}

View File

@@ -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

View File

@@ -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