Adds support for a Bucket4jRateLimiter in server webflux (#2955)
* Adds support for a Bucket4jRateLimiter * Makes bucket4j-core optional and caffeine integration test scoped * Adds Bucket4jRateLimiter auto-configuration * Updates bucket4j to 8.14.0 * Adds customizable header and adapts to async build * Adds configuration for alternative RefillStyles * Adds documentation and refillTokens property
This commit is contained in:
@@ -27,6 +27,17 @@ The default implementation of `KeyResolver` is the `PrincipalNameKeyResolver`, w
|
||||
By default, if the `KeyResolver` does not find a key, requests are denied.
|
||||
You can adjust this behavior by setting the `spring.cloud.gateway.filter.request-rate-limiter.deny-empty-key` (`true` or `false`) and `spring.cloud.gateway.filter.request-rate-limiter.empty-key-status-code` properties.
|
||||
|
||||
The following example configures a `KeyResolver` in Java:
|
||||
|
||||
.Config.java
|
||||
[source,java]
|
||||
----
|
||||
@Bean
|
||||
KeyResolver userKeyResolver() {
|
||||
return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("user"));
|
||||
}
|
||||
----
|
||||
|
||||
[NOTE]
|
||||
=====
|
||||
The `RequestRateLimiter` is not configurable with the "shortcut" notation. The following example below is _invalid_:
|
||||
@@ -81,6 +92,7 @@ The following listing configures a `redis-rate-limiter`:
|
||||
|
||||
Rate limits below `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`.
|
||||
For example, setting `replenishRate=1`, `requestedTokens=60`, and `burstCapacity=60` results in a limit of `1 request/min`.
|
||||
|
||||
.application.yml
|
||||
[source,yaml]
|
||||
----
|
||||
@@ -99,21 +111,87 @@ spring:
|
||||
|
||||
----
|
||||
|
||||
The following example configures a `KeyResolver` in Java:
|
||||
This defines a request rate limit of 10 per user. A burst of 20 is allowed, but, in the next second, only 10 requests are available.
|
||||
The `KeyResolver` is a simple one that gets the `user` request parameter
|
||||
NOTE: This is not recommended for production
|
||||
|
||||
[[bucket4j-ratelimiter]]
|
||||
== Bucket4j `RateLimiter`
|
||||
|
||||
This implementation is based on the https://bucket4j.com/[Bucket4j] Java library.
|
||||
It requires the use of the `com.bucket4j:bucket4j_jdk17-core` dependency as well as one of the https://github.com/bucket4j/bucket4j?tab=readme-ov-file#bucket4j-distributed-features[distributed persistence options].
|
||||
|
||||
In this example, we will use the Caffeine integration, which is a local cache. This can be added by including the `com.github.ben-manes.caffeine:caffeine` artifact in your dependency management. The `com.bucket4j:bucket4j_jdk17-caffeine` artifact will need to be imported as well.
|
||||
|
||||
.pom.xml
|
||||
[source,xml]
|
||||
----
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<version>${caffeine.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j_jdk17-caffeine</artifactId>
|
||||
<version>${bucket4j.version}</version>
|
||||
</dependency>
|
||||
----
|
||||
|
||||
First a bean of type `io.github.bucket4j.distributed.proxy.AsyncProxyMananger<String>` needs to be created.
|
||||
|
||||
.Config.java
|
||||
[source,java]
|
||||
----
|
||||
@Bean
|
||||
KeyResolver userKeyResolver() {
|
||||
return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("user"));
|
||||
AsyncProxyManager<String> caffeineProxyManager() {
|
||||
Caffeine<String, RemoteBucketState> builder = (Caffeine) Caffeine.newBuilder().maximumSize(100);
|
||||
return new CaffeineProxyManager<>(builder, Duration.ofMinutes(1)).asAsync();
|
||||
}
|
||||
----
|
||||
|
||||
This defines a request rate limit of 10 per user. A burst of 20 is allowed, but, in the next second, only 10 requests are available.
|
||||
The `KeyResolver` is a simple one that gets the `user` request parameter
|
||||
The `bucket4j-rate-limiter.capacity` property is the maximum number of requests a user is allowed in a single second (without any dropped requests).
|
||||
This is the number of tokens the token bucket can hold.
|
||||
Must be greater than zero.
|
||||
|
||||
The `bucket4j-rate-limiter.refillPeriod` property defines the refill period. The bucket refills at a rate of `refillTokens` per `refillPeriod`. This is a required property and uses the https://docs.spring.io/spring-boot/reference/features/external-config.html#features.external-config.typesafe-configuration-properties.conversion.periods[Spring Boot Period format].
|
||||
|
||||
The `bucket4j-rate-limiter.refillTokens` property defines how many tokens are added to the bucket in during `refillPeriod`.
|
||||
This defaults to `capacity` and must be greater than or equal to zero.
|
||||
|
||||
The `bucket4j-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`. Must be greater than zero.
|
||||
|
||||
The `bucket4j-rate-limiter.refillStyle` property defines how the bucket is refilled. The 3 options are `GREEDY` (default), `INTERVALLY` and `INTERVALLY_ALIGNED`.
|
||||
`GREEDY` tries to add the tokens to the bucket as soon as possible. `INTERVALLY`, in opposite to greedy, waits until the whole `refillPeriod` has elapsed before refilling tokens. `INTERVALLY_ALIGNED` is like `INTERVALLY`, but with a specified `timeOfFirstRefill`.
|
||||
|
||||
The `bucket4j-rate-limiter.timeOfFirstRefill` property is an `Instant` only used when `refillStyle` is set to `INTERVALLY_ALIGNED`.
|
||||
|
||||
The following example defines a request rate limit of 10 per user. A burst of 20 is allowed, but, in the next second, only 10 requests are available.
|
||||
NOTE: This is not recommended for production
|
||||
|
||||
.application.yml
|
||||
[source,yaml]
|
||||
----
|
||||
spring:
|
||||
cloud:
|
||||
gateway:
|
||||
routes:
|
||||
- id: requestratelimiter_route
|
||||
uri: https://example.org
|
||||
filters:
|
||||
- name: RequestRateLimiter
|
||||
args:
|
||||
bucket4j-rate-limiter.capacity: 20
|
||||
bucket4j-rate-limiter.refillTokens: 10
|
||||
bucket4j-rate-limiter.refillPeriod: 1s
|
||||
bucket4j-rate-limiter.requestedTokens: 1
|
||||
|
||||
----
|
||||
|
||||
[[custom-ratelimiter]]
|
||||
== Custom `RateLimiter`
|
||||
|
||||
You can also define a rate limiter as a bean that implements the `RateLimiter` interface.
|
||||
In configuration, you can reference the bean by name using SpEL.
|
||||
`#{@myRateLimiter}` is a SpEL expression that references a bean with named `myRateLimiter`.
|
||||
|
||||
6
pom.xml
6
pom.xml
@@ -51,7 +51,7 @@
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<bucket4j.version>8.10.1</bucket4j.version>
|
||||
<bucket4j.version>8.14.0</bucket4j.version>
|
||||
<blockhound.version>1.0.8.RELEASE</blockhound.version>
|
||||
<java.version>17</java.version>
|
||||
<junit-pioneer.version>2.3.0</junit-pioneer.version>
|
||||
@@ -99,12 +99,12 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<artifactId>bucket4j_jdk17-core</artifactId>
|
||||
<version>${bucket4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-caffeine</artifactId>
|
||||
<artifactId>bucket4j_jdk17-caffeine</artifactId>
|
||||
<version>${bucket4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<!-- Third party dependencies -->
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<artifactId>bucket4j_jdk17-core</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<!-- Spring test dependencies -->
|
||||
@@ -92,7 +92,7 @@
|
||||
<!-- Third party test dependencies -->
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-caffeine</artifactId>
|
||||
<artifactId>bucket4j_jdk17-caffeine</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<properties>
|
||||
<main.basedir>${basedir}/..</main.basedir>
|
||||
<grpc.version>1.68.1</grpc.version>
|
||||
<context-propagation.version>1.0.0</context-propagation.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -135,6 +136,16 @@
|
||||
<artifactId>caffeine</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j_jdk17-core</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j_jdk17-caffeine</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-observation-test</artifactId>
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.function.Supplier;
|
||||
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
|
||||
import io.github.bucket4j.distributed.proxy.AsyncProxyManager;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
@@ -123,6 +124,7 @@ import org.springframework.cloud.gateway.filter.headers.HttpHeadersFilter;
|
||||
import org.springframework.cloud.gateway.filter.headers.RemoveHopByHopHeadersFilter;
|
||||
import org.springframework.cloud.gateway.filter.headers.TransferEncodingNormalizationHeadersFilter;
|
||||
import org.springframework.cloud.gateway.filter.headers.XForwardedHeadersFilter;
|
||||
import org.springframework.cloud.gateway.filter.ratelimit.Bucket4jRateLimiter;
|
||||
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
|
||||
import org.springframework.cloud.gateway.filter.ratelimit.PrincipalNameKeyResolver;
|
||||
import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter;
|
||||
@@ -736,6 +738,20 @@ public class GatewayAutoConfiguration {
|
||||
return new ConfigurableHintsRegistrationProcessor();
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnClass(AsyncProxyManager.class)
|
||||
protected static class Bucket4jConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(AsyncProxyManager.class)
|
||||
@ConditionalOnEnabledFilter(RequestRateLimiterGatewayFilterFactory.class)
|
||||
public Bucket4jRateLimiter bucket4jRateLimiter(AsyncProxyManager<String> proxyManager,
|
||||
ConfigurationService configurationService) {
|
||||
return new Bucket4jRateLimiter(proxyManager, configurationService);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnClass(HttpClient.class)
|
||||
protected static class NettyConfiguration {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright 2013-2023 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 java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.BandwidthBuilder.BandwidthBuilderBuildStage;
|
||||
import io.github.bucket4j.BandwidthBuilder.BandwidthBuilderRefillStage;
|
||||
import io.github.bucket4j.BucketConfiguration;
|
||||
import io.github.bucket4j.ConsumptionProbe;
|
||||
import io.github.bucket4j.distributed.AsyncBucketProxy;
|
||||
import io.github.bucket4j.distributed.proxy.AsyncProxyManager;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator;
|
||||
import org.springframework.cloud.gateway.support.ConfigurationService;
|
||||
import org.springframework.core.style.ToStringCreator;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
public class Bucket4jRateLimiter extends AbstractRateLimiter<Bucket4jRateLimiter.Config> {
|
||||
|
||||
/**
|
||||
* Default Header Name.
|
||||
*/
|
||||
public static final String DEFAULT_HEADER_NAME = "X-RateLimit-Remaining";
|
||||
|
||||
/**
|
||||
* Redis Rate Limiter property name.
|
||||
*/
|
||||
public static final String CONFIGURATION_PROPERTY_NAME = "bucket4j-rate-limiter";
|
||||
|
||||
private final Log log = LogFactory.getLog(getClass());
|
||||
|
||||
private final AsyncProxyManager<String> proxyManager;
|
||||
|
||||
private Config defaultConfig = new Config();
|
||||
|
||||
public Bucket4jRateLimiter(AsyncProxyManager<String> proxyManager, ConfigurationService configurationService) {
|
||||
super(Config.class, CONFIGURATION_PROPERTY_NAME, configurationService);
|
||||
this.proxyManager = proxyManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Response> isAllowed(String routeId, String id) {
|
||||
Config routeConfig = loadRouteConfiguration(routeId);
|
||||
|
||||
AsyncBucketProxy bucket = proxyManager.builder().build(id, routeConfig.getConfigurationSupplier());
|
||||
CompletableFuture<ConsumptionProbe> bucketFuture = bucket
|
||||
.tryConsumeAndReturnRemaining(routeConfig.getRequestedTokens());
|
||||
return Mono.fromFuture(bucketFuture).onErrorResume(throwable -> {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Error calling Bucket4J rate limiter", throwable);
|
||||
}
|
||||
return Mono.just(ConsumptionProbe.rejected(-1, -1, -1));
|
||||
}).map(consumptionProbe -> {
|
||||
boolean allowed = consumptionProbe.isConsumed();
|
||||
long remainingTokens = consumptionProbe.getRemainingTokens();
|
||||
Response response = new Response(allowed, getHeaders(routeConfig, remainingTokens));
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("response: " + response);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
protected Config loadRouteConfiguration(String routeId) {
|
||||
Config routeConfig = getConfig().getOrDefault(routeId, defaultConfig);
|
||||
|
||||
if (routeConfig == null) {
|
||||
routeConfig = getConfig().get(RouteDefinitionRouteLocator.DEFAULT_FILTERS);
|
||||
}
|
||||
|
||||
if (routeConfig == null) {
|
||||
throw new IllegalArgumentException("No Configuration found for route " + routeId + " or defaultFilters");
|
||||
}
|
||||
return routeConfig;
|
||||
}
|
||||
|
||||
public Map<String, String> getHeaders(Config config, Long tokensLeft) {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
// TODO: configurable isIncludeHeaders?
|
||||
// if (isIncludeHeaders()) {
|
||||
headers.put(config.getHeaderName(), tokensLeft.toString());
|
||||
// }
|
||||
return headers;
|
||||
}
|
||||
|
||||
public static class Config {
|
||||
|
||||
private static final Function<Config, BucketConfiguration> DEFAULT_CONFIGURATION_BUILDER = config -> {
|
||||
BandwidthBuilderRefillStage bandwidth = Bandwidth.builder().capacity(config.getCapacity());
|
||||
|
||||
long refillTokens = config.getRefillTokens() == null ? config.getCapacity() : config.getRefillTokens();
|
||||
|
||||
BandwidthBuilderBuildStage refill = switch (config.getRefillStyle()) {
|
||||
case GREEDY -> bandwidth.refillGreedy(refillTokens, config.getRefillPeriod());
|
||||
case INTERVALLY -> bandwidth.refillIntervally(refillTokens, config.getRefillPeriod());
|
||||
case INTERVALLY_ALIGNED -> bandwidth.refillIntervallyAligned(refillTokens, config.getRefillPeriod(),
|
||||
config.getTimeOfFirstRefill());
|
||||
};
|
||||
|
||||
return BucketConfiguration.builder().addLimit(refill.build()).build();
|
||||
};
|
||||
|
||||
long capacity;
|
||||
|
||||
Function<Config, BucketConfiguration> configurationBuilder = DEFAULT_CONFIGURATION_BUILDER;
|
||||
|
||||
Supplier<CompletableFuture<BucketConfiguration>> configurationSupplier;
|
||||
|
||||
String headerName = DEFAULT_HEADER_NAME;
|
||||
|
||||
Duration refillPeriod;
|
||||
|
||||
RefillStyle refillStyle = RefillStyle.GREEDY;
|
||||
|
||||
Long refillTokens;
|
||||
|
||||
long requestedTokens = 1;
|
||||
|
||||
// for RefillStyle.INTERVALLY_ALIGNED
|
||||
Instant timeOfFirstRefill;
|
||||
|
||||
public long getCapacity() {
|
||||
return capacity;
|
||||
}
|
||||
|
||||
public Config setCapacity(long capacity) {
|
||||
this.capacity = capacity;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Function<Config, BucketConfiguration> getConfigurationBuilder() {
|
||||
return configurationBuilder;
|
||||
}
|
||||
|
||||
public void setConfigurationBuilder(Function<Config, BucketConfiguration> configurationBuilder) {
|
||||
Assert.notNull(configurationBuilder, "configurationBuilder may not be null");
|
||||
this.configurationBuilder = configurationBuilder;
|
||||
}
|
||||
|
||||
public Supplier<CompletableFuture<BucketConfiguration>> getConfigurationSupplier() {
|
||||
if (configurationSupplier != null) {
|
||||
return configurationSupplier;
|
||||
}
|
||||
return () -> CompletableFuture.completedFuture(getConfigurationBuilder().apply(this));
|
||||
}
|
||||
|
||||
public void setConfigurationSupplier(Function<Config, BucketConfiguration> configurationBuilder) {
|
||||
Assert.notNull(configurationBuilder, "configurationBuilder may not be null");
|
||||
this.configurationBuilder = configurationBuilder;
|
||||
}
|
||||
|
||||
public String getHeaderName() {
|
||||
return headerName;
|
||||
}
|
||||
|
||||
public Config setHeaderName(String headerName) {
|
||||
Assert.notNull(headerName, "headerName may not be null");
|
||||
this.headerName = headerName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Duration getRefillPeriod() {
|
||||
return refillPeriod;
|
||||
}
|
||||
|
||||
public Config setRefillPeriod(Duration refillPeriod) {
|
||||
this.refillPeriod = refillPeriod;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RefillStyle getRefillStyle() {
|
||||
return refillStyle;
|
||||
}
|
||||
|
||||
public Config setRefillStyle(RefillStyle refillStyle) {
|
||||
this.refillStyle = refillStyle;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Long getRefillTokens() {
|
||||
return refillTokens;
|
||||
}
|
||||
|
||||
public Config setRefillTokens(Long refillTokens) {
|
||||
this.refillTokens = refillTokens;
|
||||
return this;
|
||||
}
|
||||
|
||||
public long getRequestedTokens() {
|
||||
return requestedTokens;
|
||||
}
|
||||
|
||||
public Config setRequestedTokens(long requestedTokens) {
|
||||
this.requestedTokens = requestedTokens;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Instant getTimeOfFirstRefill() {
|
||||
return timeOfFirstRefill;
|
||||
}
|
||||
|
||||
public Config setTimeOfFirstRefill(Instant timeOfFirstRefill) {
|
||||
this.timeOfFirstRefill = timeOfFirstRefill;
|
||||
return this;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return new ToStringCreator(this).append("capacity", capacity)
|
||||
.append("headerName", headerName)
|
||||
.append("refillPeriod", refillPeriod)
|
||||
.append("refillStyle", refillStyle)
|
||||
.append("refillTokens", refillTokens)
|
||||
.append("requestedTokens", requestedTokens)
|
||||
.append("timeOfFirstRefill", timeOfFirstRefill)
|
||||
.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public enum RefillStyle {
|
||||
|
||||
/**
|
||||
* Greedy tries to add the tokens to the bucket as soon as possible.
|
||||
*/
|
||||
GREEDY,
|
||||
|
||||
/**
|
||||
* Intervally, in opposite to greedy, waits until the whole refillPeriod has elapsed
|
||||
* before refilling tokens.
|
||||
*/
|
||||
INTERVALLY,
|
||||
|
||||
/**
|
||||
* IntervallyAligned, like Intervally, but with a specified first refill time.
|
||||
*/
|
||||
INTERVALLY_ALIGNED;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright 2013-2020 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 java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import io.github.bucket4j.caffeine.CaffeineProxyManager;
|
||||
import io.github.bucket4j.distributed.proxy.AsyncProxyManager;
|
||||
import io.github.bucket4j.distributed.remote.RemoteBucketState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junitpioneer.jupiter.RetryingTest;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.cloud.gateway.filter.ratelimit.Bucket4jRateLimiter.RefillStyle;
|
||||
import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter.Response;
|
||||
import org.springframework.cloud.gateway.test.BaseWebClientTests;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
|
||||
|
||||
/**
|
||||
* @author Spencer Gibb
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = RANDOM_PORT, properties = "spring.cloud.gateway.redis.enabled=false")
|
||||
@DirtiesContext
|
||||
public class Bucket4jRateLimiterTests extends BaseWebClientTests {
|
||||
|
||||
@Autowired
|
||||
private Bucket4jRateLimiter rateLimiter;
|
||||
|
||||
@RetryingTest(3)
|
||||
public void bucket4jRateLimiterGreedyWorks() throws Exception {
|
||||
bucket4jRateLimiterWorks(RefillStyle.GREEDY);
|
||||
}
|
||||
|
||||
@RetryingTest(3)
|
||||
public void bucket4jRateLimiterIntervallyWorks() throws Exception {
|
||||
bucket4jRateLimiterWorks(RefillStyle.INTERVALLY);
|
||||
}
|
||||
|
||||
public void bucket4jRateLimiterWorks(RefillStyle refillStyle) throws Exception {
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
long capacity = 10;
|
||||
int requestedTokens = 1;
|
||||
|
||||
String routeId = "myroute";
|
||||
rateLimiter.getConfig()
|
||||
.put(routeId,
|
||||
new Bucket4jRateLimiter.Config().setRefillStyle(refillStyle)
|
||||
.setHeaderName("X-RateLimit-Custom")
|
||||
.setCapacity(capacity)
|
||||
.setRefillPeriod(Duration.ofSeconds(1)));
|
||||
|
||||
checkLimitEnforced(id, capacity, requestedTokens, routeId);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bucket4jRateLimiterIsAllowedFalseWorks() throws Exception {
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
int capacity = 1;
|
||||
int requestedTokens = 2;
|
||||
|
||||
String routeId = "zero_capacity_route";
|
||||
rateLimiter.getConfig()
|
||||
.put(routeId,
|
||||
new Bucket4jRateLimiter.Config().setCapacity(capacity)
|
||||
.setRefillPeriod(Duration.ofSeconds(1))
|
||||
.setRequestedTokens(requestedTokens));
|
||||
|
||||
Response response = rateLimiter.isAllowed(routeId, id).block();
|
||||
assertThat(response.isAllowed()).isFalse();
|
||||
}
|
||||
|
||||
private void checkLimitEnforced(String id, long capacity, int requestedTokens, String routeId)
|
||||
throws InterruptedException {
|
||||
// Bursts work
|
||||
simulateBurst(id, capacity, requestedTokens, routeId);
|
||||
|
||||
checkLimitReached(id, routeId, capacity);
|
||||
|
||||
Thread.sleep(Math.max(1, requestedTokens / capacity) * 1000);
|
||||
|
||||
// # After the burst is done, check the steady state
|
||||
checkSteadyState(id, capacity, routeId);
|
||||
}
|
||||
|
||||
private void simulateBurst(String id, long capacity, int requestedTokens, String routeId) {
|
||||
long previousRemaining = capacity;
|
||||
for (int i = 0; i < capacity / requestedTokens; i++) {
|
||||
Response response = rateLimiter.isAllowed(routeId, id).block();
|
||||
assertThat(response.isAllowed()).as("Burst # %s is allowed", i).isTrue();
|
||||
assertThat(response.getHeaders()).containsKey("X-RateLimit-Custom");
|
||||
System.err.println("response headers: " + response.getHeaders());
|
||||
long remaining = Long.parseLong(response.getHeaders().get("X-RateLimit-Custom"));
|
||||
assertThat(remaining).isLessThan(previousRemaining);
|
||||
previousRemaining = remaining;
|
||||
// TODO: assert additional headers
|
||||
}
|
||||
}
|
||||
|
||||
private void checkLimitReached(String id, String routeId, long capacity) {
|
||||
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("capacity # %s is not allowed", capacity).isFalse();
|
||||
}
|
||||
|
||||
private void checkSteadyState(String id, long capacity, String routeId) {
|
||||
Response response;
|
||||
for (int i = 0; i < capacity; 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", capacity).isFalse();
|
||||
}
|
||||
|
||||
@EnableAutoConfiguration
|
||||
@SpringBootConfiguration
|
||||
@Import(DefaultTestConfig.class)
|
||||
public static class TestConfig {
|
||||
|
||||
@Bean
|
||||
public AsyncProxyManager<String> caffeineProxyManager() {
|
||||
Caffeine<String, RemoteBucketState> builder = (Caffeine) Caffeine.newBuilder().maximumSize(100);
|
||||
return new CaffeineProxyManager<>(builder, Duration.ofMinutes(1)).asAsync();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable;
|
||||
import org.junitpioneer.jupiter.RetryingTest;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
@@ -100,6 +101,7 @@ public class RedisRateLimiterTests extends BaseWebClientTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisabledIfEnvironmentVariable(named = "GITHUB_ACTIONS", matches = "true")
|
||||
public void redisRateLimiterWorksForMultipleRoutes() throws Exception {
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user