From 169a672a7f4f96d481760a6c3bc6b0ae4674372c Mon Sep 17 00:00:00 2001 From: spencergibb Date: Mon, 15 May 2023 13:49:06 -0400 Subject: [PATCH 01/11] Adds support for a Bucket4jRateLimiter --- spring-cloud-gateway-server/pom.xml | 12 ++ .../filter/ratelimit/Bucket4jRateLimiter.java | 152 +++++++++++++++++ .../ratelimit/Bucket4jRateLimiterTests.java | 155 ++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java create mode 100644 spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java diff --git a/spring-cloud-gateway-server/pom.xml b/spring-cloud-gateway-server/pom.xml index c365ed4be2..a8577bce7d 100644 --- a/spring-cloud-gateway-server/pom.xml +++ b/spring-cloud-gateway-server/pom.xml @@ -17,6 +17,8 @@ ${basedir}/.. 1.68.1 + 1.0.0 + 8.3.0 @@ -135,6 +137,16 @@ caffeine true + + com.bucket4j + bucket4j-core + ${bucket4j-core.version} + + + com.bucket4j + bucket4j-caffeine + ${bucket4j-core.version} + io.micrometer micrometer-observation-test diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java new file mode 100644 index 0000000000..72f2c91c9b --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java @@ -0,0 +1,152 @@ +/* + * 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.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import io.github.bucket4j.Bandwidth; +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; + +public class Bucket4jRateLimiter extends AbstractRateLimiter { + + /** + * 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 proxyManager; + + private Config defaultConfig = new Config(); + + public Bucket4jRateLimiter(AsyncProxyManager proxyManager, ConfigurationService configurationService) { + super(Config.class, CONFIGURATION_PROPERTY_NAME, configurationService); + this.proxyManager = proxyManager; + } + + @Override + public Mono isAllowed(String routeId, String id) { + Config routeConfig = loadRouteConfiguration(routeId); + + BucketConfiguration bucketConfiguration = getBucketConfiguration(routeConfig); + + AsyncBucketProxy bucket = proxyManager.builder().build(id, bucketConfiguration); + CompletableFuture 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 static BucketConfiguration getBucketConfiguration(Config routeConfig) { + return BucketConfiguration.builder() + .addLimit(Bandwidth.simple(routeConfig.getCapacity(), routeConfig.getPeriod())).build(); + } + + 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 getHeaders(Config config, Long tokensLeft) { + Map headers = new HashMap<>(); + // if (isIncludeHeaders()) { + // TODO: configurable headers ala RedisRateLimiter + headers.put("X-RateLimit-Remaining", tokensLeft.toString()); + // } + return headers; + } + + public static class Config { + + //TODO: create simple and classic w/Refill + + long capacity; + + Duration period; + + private long requestedTokens = 1; + + public long getCapacity() { + return capacity; + } + + public Config setCapacity(long capacity) { + this.capacity = capacity; + return this; + } + + public Duration getPeriod() { + return period; + } + + public Config setPeriod(Duration period) { + this.period = period; + return this; + } + + public long getRequestedTokens() { + return requestedTokens; + } + + public Config setRequestedTokens(long requestedTokens) { + this.requestedTokens = requestedTokens; + return this; + } + + public String toString() { + return new ToStringCreator(this).append("capacity", capacity).append("requestedTokens", requestedTokens) + .append("period", period).toString(); + } + + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java new file mode 100644 index 0000000000..9efb35eb25 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java @@ -0,0 +1,155 @@ +/* + * 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.RateLimiter.Response; +import org.springframework.cloud.gateway.support.ConfigurationService; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +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; + +/** + * see + * https://gist.github.com/ptarjan/e38f45f2dfe601419ca3af937fff574d#file-1-check_request_rate_limiter-rb-L36-L62 + * + * @author Spencer Gibb + * @author Ronny Bräunlich + * @author Denis Cutic + */ +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +public class Bucket4jRateLimiterTests extends BaseWebClientTests { + + @Autowired + private Bucket4jRateLimiter rateLimiter; + + @RetryingTest(3) + public void bucket4jRateLimiterWorks() throws Exception { + String id = UUID.randomUUID().toString(); + + long capacity = 10; + // int burstCapacity = 2 * capacity; + int requestedTokens = 1; + + String routeId = "myroute"; + rateLimiter.getConfig().put(routeId, + new Bucket4jRateLimiter.Config().setCapacity(capacity).setPeriod(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) + .setPeriod(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-Remaining"); + System.err.println("response headers: " + response.getHeaders()); + long remaining = Long.parseLong(response.getHeaders().get("X-RateLimit-Remaining")); + 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 + @Primary + public Bucket4jRateLimiter bucket4jRateLimiter(AsyncProxyManager proxyManager, + ConfigurationService configurationService) { + return new Bucket4jRateLimiter(proxyManager, configurationService); + } + + @Bean + public AsyncProxyManager caffeineProxyManager() { + Caffeine builder = (Caffeine) Caffeine.newBuilder().maximumSize(100); + return new CaffeineProxyManager<>(builder, Duration.ofMinutes(1)).asAsync(); + } + + } + +} From 27b1213c253e18c2144fce60acbba41afafd8051 Mon Sep 17 00:00:00 2001 From: spencergibb Date: Mon, 15 May 2023 13:52:14 -0400 Subject: [PATCH 02/11] Makes bucket4j-core optional and caffeine integration test scoped --- spring-cloud-gateway-server/pom.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spring-cloud-gateway-server/pom.xml b/spring-cloud-gateway-server/pom.xml index a8577bce7d..d646df849b 100644 --- a/spring-cloud-gateway-server/pom.xml +++ b/spring-cloud-gateway-server/pom.xml @@ -141,11 +141,13 @@ com.bucket4j bucket4j-core ${bucket4j-core.version} + true com.bucket4j bucket4j-caffeine ${bucket4j-core.version} + test io.micrometer From cfd48e6f38f8809570599d9ca6b586d56c1881fe Mon Sep 17 00:00:00 2001 From: spencergibb Date: Wed, 4 Dec 2024 11:40:30 -0500 Subject: [PATCH 03/11] Formatting --- .../filter/ratelimit/Bucket4jRateLimiter.java | 13 ++++++++----- .../filter/ratelimit/Bucket4jRateLimiterTests.java | 11 +++++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java index 72f2c91c9b..56a4a6fb05 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java @@ -60,7 +60,7 @@ public Mono isAllowed(String routeId, String id) { AsyncBucketProxy bucket = proxyManager.builder().build(id, bucketConfiguration); CompletableFuture bucketFuture = bucket - .tryConsumeAndReturnRemaining(routeConfig.getRequestedTokens()); + .tryConsumeAndReturnRemaining(routeConfig.getRequestedTokens()); return Mono.fromFuture(bucketFuture).onErrorResume(throwable -> { if (log.isDebugEnabled()) { log.debug("Error calling Bucket4J rate limiter", throwable); @@ -80,7 +80,8 @@ public Mono isAllowed(String routeId, String id) { protected static BucketConfiguration getBucketConfiguration(Config routeConfig) { return BucketConfiguration.builder() - .addLimit(Bandwidth.simple(routeConfig.getCapacity(), routeConfig.getPeriod())).build(); + .addLimit(Bandwidth.simple(routeConfig.getCapacity(), routeConfig.getPeriod())) + .build(); } protected Config loadRouteConfiguration(String routeId) { @@ -107,7 +108,7 @@ public Map getHeaders(Config config, Long tokensLeft) { public static class Config { - //TODO: create simple and classic w/Refill + // TODO: create simple and classic w/Refill long capacity; @@ -143,8 +144,10 @@ public Config setRequestedTokens(long requestedTokens) { } public String toString() { - return new ToStringCreator(this).append("capacity", capacity).append("requestedTokens", requestedTokens) - .append("period", period).toString(); + return new ToStringCreator(this).append("capacity", capacity) + .append("requestedTokens", requestedTokens) + .append("period", period) + .toString(); } } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java index 9efb35eb25..dfc62e3fcd 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java @@ -65,8 +65,8 @@ public void bucket4jRateLimiterWorks() throws Exception { int requestedTokens = 1; String routeId = "myroute"; - rateLimiter.getConfig().put(routeId, - new Bucket4jRateLimiter.Config().setCapacity(capacity).setPeriod(Duration.ofSeconds(1))); + rateLimiter.getConfig() + .put(routeId, new Bucket4jRateLimiter.Config().setCapacity(capacity).setPeriod(Duration.ofSeconds(1))); checkLimitEnforced(id, capacity, requestedTokens, routeId); } @@ -79,8 +79,11 @@ public void bucket4jRateLimiterIsAllowedFalseWorks() throws Exception { int requestedTokens = 2; String routeId = "zero_capacity_route"; - rateLimiter.getConfig().put(routeId, new Bucket4jRateLimiter.Config().setCapacity(capacity) - .setPeriod(Duration.ofSeconds(1)).setRequestedTokens(requestedTokens)); + rateLimiter.getConfig() + .put(routeId, + new Bucket4jRateLimiter.Config().setCapacity(capacity) + .setPeriod(Duration.ofSeconds(1)) + .setRequestedTokens(requestedTokens)); Response response = rateLimiter.isAllowed(routeId, id).block(); assertThat(response.isAllowed()).isFalse(); From 3122ba7bf1a76c9a0652aa5ad27f7b759df17a3f Mon Sep 17 00:00:00 2001 From: spencergibb Date: Wed, 4 Dec 2024 11:48:30 -0500 Subject: [PATCH 04/11] Adds Bucket4jRateLimiter auto-configuration --- .../gateway/config/GatewayAutoConfiguration.java | 15 +++++++++++++++ .../ratelimit/Bucket4jRateLimiterTests.java | 16 +--------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index 2b89cc0e49..acdb448b1d 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -25,6 +25,7 @@ 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.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,19 @@ static ConfigurableHintsRegistrationProcessor configurableHintsRegistrationProce return new ConfigurableHintsRegistrationProcessor(); } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(AsyncProxyManager.class) + protected static class Bucket4jConfiguration { + @Bean + @ConditionalOnBean(AsyncProxyManager.class) + @ConditionalOnEnabledFilter(RequestRateLimiterGatewayFilterFactory.class) + public Bucket4jRateLimiter bucket4jRateLimiter(AsyncProxyManager proxyManager, + ConfigurationService configurationService) { + return new Bucket4jRateLimiter(proxyManager, configurationService); + } + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(HttpClient.class) protected static class NettyConfiguration { diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java index dfc62e3fcd..11eae65d35 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java @@ -31,25 +31,18 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter.Response; -import org.springframework.cloud.gateway.support.ConfigurationService; import org.springframework.cloud.gateway.test.BaseWebClientTests; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import org.springframework.context.annotation.Primary; 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; /** - * see - * https://gist.github.com/ptarjan/e38f45f2dfe601419ca3af937fff574d#file-1-check_request_rate_limiter-rb-L36-L62 - * * @author Spencer Gibb - * @author Ronny Bräunlich - * @author Denis Cutic */ -@SpringBootTest(webEnvironment = RANDOM_PORT) +@SpringBootTest(webEnvironment = RANDOM_PORT, properties = "spring.cloud.gateway.redis.enabled=false") @DirtiesContext public class Bucket4jRateLimiterTests extends BaseWebClientTests { @@ -140,13 +133,6 @@ private void checkSteadyState(String id, long capacity, String routeId) { @Import(DefaultTestConfig.class) public static class TestConfig { - @Bean - @Primary - public Bucket4jRateLimiter bucket4jRateLimiter(AsyncProxyManager proxyManager, - ConfigurationService configurationService) { - return new Bucket4jRateLimiter(proxyManager, configurationService); - } - @Bean public AsyncProxyManager caffeineProxyManager() { Caffeine builder = (Caffeine) Caffeine.newBuilder().maximumSize(100); From 7db16a73f2a4277edd4c5fdc4f991824a2cbaee3 Mon Sep 17 00:00:00 2001 From: spencergibb Date: Wed, 4 Dec 2024 12:04:37 -0500 Subject: [PATCH 05/11] Updates bucket4j to 8.14.0 --- pom.xml | 6 +++--- spring-cloud-gateway-server-mvc/pom.xml | 4 ++-- spring-cloud-gateway-server/pom.xml | 7 ++----- .../cloud/gateway/config/GatewayAutoConfiguration.java | 3 ++- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/pom.xml b/pom.xml index 87438173a4..f0da055653 100644 --- a/pom.xml +++ b/pom.xml @@ -51,7 +51,7 @@ UTF-8 UTF-8 - 8.10.1 + 8.14.0 1.0.8.RELEASE 17 2.3.0 @@ -99,12 +99,12 @@ com.bucket4j - bucket4j-core + bucket4j_jdk17-core ${bucket4j.version} com.bucket4j - bucket4j-caffeine + bucket4j_jdk17-caffeine ${bucket4j.version} diff --git a/spring-cloud-gateway-server-mvc/pom.xml b/spring-cloud-gateway-server-mvc/pom.xml index ba17bdf714..b20a9cc2cb 100644 --- a/spring-cloud-gateway-server-mvc/pom.xml +++ b/spring-cloud-gateway-server-mvc/pom.xml @@ -75,7 +75,7 @@ com.bucket4j - bucket4j-core + bucket4j_jdk17-core true @@ -92,7 +92,7 @@ com.bucket4j - bucket4j-caffeine + bucket4j_jdk17-caffeine test diff --git a/spring-cloud-gateway-server/pom.xml b/spring-cloud-gateway-server/pom.xml index d646df849b..1624f4b05b 100644 --- a/spring-cloud-gateway-server/pom.xml +++ b/spring-cloud-gateway-server/pom.xml @@ -18,7 +18,6 @@ ${basedir}/.. 1.68.1 1.0.0 - 8.3.0 @@ -139,14 +138,12 @@ com.bucket4j - bucket4j-core - ${bucket4j-core.version} + bucket4j_jdk17-core true com.bucket4j - bucket4j-caffeine - ${bucket4j-core.version} + bucket4j_jdk17-caffeine test diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index acdb448b1d..85767b848f 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -738,10 +738,10 @@ static ConfigurableHintsRegistrationProcessor configurableHintsRegistrationProce return new ConfigurableHintsRegistrationProcessor(); } - @Configuration(proxyBeanMethods = false) @ConditionalOnClass(AsyncProxyManager.class) protected static class Bucket4jConfiguration { + @Bean @ConditionalOnBean(AsyncProxyManager.class) @ConditionalOnEnabledFilter(RequestRateLimiterGatewayFilterFactory.class) @@ -749,6 +749,7 @@ public Bucket4jRateLimiter bucket4jRateLimiter(AsyncProxyManager proxyMa ConfigurationService configurationService) { return new Bucket4jRateLimiter(proxyManager, configurationService); } + } @Configuration(proxyBeanMethods = false) From d68ac81d7bab2b3aec437eb36b6c059b309353db Mon Sep 17 00:00:00 2001 From: spencergibb Date: Wed, 4 Dec 2024 12:33:49 -0500 Subject: [PATCH 06/11] Adds customizable header and adapts to async build --- .../filter/ratelimit/Bucket4jRateLimiter.java | 72 +++++++++++++++---- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java index 56a4a6fb05..7c31c37615 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java @@ -20,6 +20,8 @@ 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.BucketConfiguration; @@ -33,9 +35,15 @@ 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 { + /** + * Default Header Name. + */ + public static final String DEFAULT_HEADER_NAME = "X-RateLimit-Remaining"; + /** * Redis Rate Limiter property name. */ @@ -56,9 +64,7 @@ public Bucket4jRateLimiter(AsyncProxyManager proxyManager, Configuration public Mono isAllowed(String routeId, String id) { Config routeConfig = loadRouteConfiguration(routeId); - BucketConfiguration bucketConfiguration = getBucketConfiguration(routeConfig); - - AsyncBucketProxy bucket = proxyManager.builder().build(id, bucketConfiguration); + AsyncBucketProxy bucket = proxyManager.builder().build(id, routeConfig.getConfigurationSupplier()); CompletableFuture bucketFuture = bucket .tryConsumeAndReturnRemaining(routeConfig.getRequestedTokens()); return Mono.fromFuture(bucketFuture).onErrorResume(throwable -> { @@ -78,12 +84,6 @@ public Mono isAllowed(String routeId, String id) { }); } - protected static BucketConfiguration getBucketConfiguration(Config routeConfig) { - return BucketConfiguration.builder() - .addLimit(Bandwidth.simple(routeConfig.getCapacity(), routeConfig.getPeriod())) - .build(); - } - protected Config loadRouteConfiguration(String routeId) { Config routeConfig = getConfig().getOrDefault(routeId, defaultConfig); @@ -99,19 +99,33 @@ protected Config loadRouteConfiguration(String routeId) { public Map getHeaders(Config config, Long tokensLeft) { Map headers = new HashMap<>(); + // TODO: configurable isIncludeHeaders? // if (isIncludeHeaders()) { - // TODO: configurable headers ala RedisRateLimiter - headers.put("X-RateLimit-Remaining", tokensLeft.toString()); + headers.put(config.getHeaderName(), tokensLeft.toString()); // } return headers; } public static class Config { - // TODO: create simple and classic w/Refill + // TODO: create simple and classic w/Refill (see builder) + + private static final Function DEFAULT_CONFIGURATION_BUILDER = config -> BucketConfiguration + .builder() + .addLimit(Bandwidth.builder() + .capacity(config.getCapacity()) + .refillGreedy(config.getCapacity(), config.getPeriod()) + .build()) + .build(); long capacity; + Function configurationBuilder = DEFAULT_CONFIGURATION_BUILDER; + + Supplier> configurationSupplier; + + String headerName = DEFAULT_HEADER_NAME; + Duration period; private long requestedTokens = 1; @@ -125,6 +139,37 @@ public Config setCapacity(long capacity) { return this; } + public Function getConfigurationBuilder() { + return configurationBuilder; + } + + public void setConfigurationBuilder(Function configurationBuilder) { + Assert.notNull(configurationBuilder, "configurationBuilder may not be null"); + this.configurationBuilder = configurationBuilder; + } + + public Supplier> getConfigurationSupplier() { + if (configurationSupplier != null) { + return configurationSupplier; + } + return () -> CompletableFuture.completedFuture(getConfigurationBuilder().apply(this)); + } + + public void setConfigurationSupplier(Function 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 getPeriod() { return period; } @@ -145,8 +190,9 @@ public Config setRequestedTokens(long requestedTokens) { public String toString() { return new ToStringCreator(this).append("capacity", capacity) - .append("requestedTokens", requestedTokens) + .append("headerName", headerName) .append("period", period) + .append("requestedTokens", requestedTokens) .toString(); } From fe6ca8fce3cfe4907d05b52056ea71cf272e702f Mon Sep 17 00:00:00 2001 From: spencergibb Date: Wed, 4 Dec 2024 12:35:00 -0500 Subject: [PATCH 07/11] clarifies comment --- .../cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java index 7c31c37615..8642a2fd24 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java @@ -108,7 +108,7 @@ public Map getHeaders(Config config, Long tokensLeft) { public static class Config { - // TODO: create simple and classic w/Refill (see builder) + // TODO: create simple and classic w/Refill (see Bandwidth) private static final Function DEFAULT_CONFIGURATION_BUILDER = config -> BucketConfiguration .builder() From 44964e93e4bc9f11ac309063e42ac054cd0590e4 Mon Sep 17 00:00:00 2001 From: spencergibb Date: Wed, 4 Dec 2024 12:37:40 -0500 Subject: [PATCH 08/11] clarifies comment --- .../cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java index 8642a2fd24..b9ef3fa8f7 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java @@ -108,8 +108,9 @@ public Map getHeaders(Config config, Long tokensLeft) { public static class Config { - // TODO: create simple and classic w/Refill (see Bandwidth) + // TODO: options for refill Intervally, IntervallyAligned + // default using deprecated Bandwidth.simple private static final Function DEFAULT_CONFIGURATION_BUILDER = config -> BucketConfiguration .builder() .addLimit(Bandwidth.builder() From 17425cfcf4ca61df25909772328ce800801d9371 Mon Sep 17 00:00:00 2001 From: spencergibb Date: Wed, 4 Dec 2024 13:28:45 -0500 Subject: [PATCH 09/11] Adds configuration for alternative RefillStyles --- .../filter/ratelimit/Bucket4jRateLimiter.java | 70 ++++++++++++++++--- .../ratelimit/Bucket4jRateLimiterTests.java | 22 ++++-- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java index b9ef3fa8f7..49f51d7e78 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java @@ -17,6 +17,7 @@ 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; @@ -24,6 +25,8 @@ 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; @@ -108,16 +111,18 @@ public Map getHeaders(Config config, Long tokensLeft) { public static class Config { - // TODO: options for refill Intervally, IntervallyAligned + private static final Function DEFAULT_CONFIGURATION_BUILDER = config -> { + BandwidthBuilderRefillStage bandwidth = Bandwidth.builder().capacity(config.getCapacity()); - // default using deprecated Bandwidth.simple - private static final Function DEFAULT_CONFIGURATION_BUILDER = config -> BucketConfiguration - .builder() - .addLimit(Bandwidth.builder() - .capacity(config.getCapacity()) - .refillGreedy(config.getCapacity(), config.getPeriod()) - .build()) - .build(); + BandwidthBuilderBuildStage refill = switch (config.getRefillStyle()) { + case GREEDY -> bandwidth.refillGreedy(config.getCapacity(), config.getPeriod()); + case INTERVALLY -> bandwidth.refillIntervally(config.getCapacity(), config.getPeriod()); + case INTERVALLY_ALIGNED -> bandwidth.refillIntervallyAligned(config.getCapacity(), config.getPeriod(), + config.getTimeOfFirstRefill()); + }; + + return BucketConfiguration.builder().addLimit(refill.build()).build(); + }; long capacity; @@ -129,7 +134,12 @@ public static class Config { Duration period; - private long requestedTokens = 1; + RefillStyle refillStyle = RefillStyle.GREEDY; + + long requestedTokens = 1; + + // for RefillStyle.INTERVALLY_ALIGNED + Instant timeOfFirstRefill; public long getCapacity() { return capacity; @@ -180,6 +190,15 @@ public Config setPeriod(Duration period) { return this; } + public RefillStyle getRefillStyle() { + return refillStyle; + } + + public Config setRefillStyle(RefillStyle refillStyle) { + this.refillStyle = refillStyle; + return this; + } + public long getRequestedTokens() { return requestedTokens; } @@ -189,14 +208,45 @@ public Config setRequestedTokens(long 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("period", period) + .append("refillStyle", refillStyle) .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 period has elapsed + * before refilling tokens. + */ + INTERVALLY, + + /** + * IntervallyAligned, like Intervally, but with an specified first refill time. + */ + INTERVALLY_ALIGNED; + + } + } diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java index 11eae65d35..9cf0d2e3df 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java @@ -30,6 +30,7 @@ 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; @@ -50,7 +51,16 @@ public class Bucket4jRateLimiterTests extends BaseWebClientTests { private Bucket4jRateLimiter rateLimiter; @RetryingTest(3) - public void bucket4jRateLimiterWorks() throws Exception { + 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; @@ -59,7 +69,11 @@ public void bucket4jRateLimiterWorks() throws Exception { String routeId = "myroute"; rateLimiter.getConfig() - .put(routeId, new Bucket4jRateLimiter.Config().setCapacity(capacity).setPeriod(Duration.ofSeconds(1))); + .put(routeId, + new Bucket4jRateLimiter.Config().setRefillStyle(refillStyle) + .setHeaderName("X-RateLimit-Custom") + .setCapacity(capacity) + .setPeriod(Duration.ofSeconds(1))); checkLimitEnforced(id, capacity, requestedTokens, routeId); } @@ -100,9 +114,9 @@ private void simulateBurst(String id, long capacity, int requestedTokens, String 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-Remaining"); + assertThat(response.getHeaders()).containsKey("X-RateLimit-Custom"); System.err.println("response headers: " + response.getHeaders()); - long remaining = Long.parseLong(response.getHeaders().get("X-RateLimit-Remaining")); + long remaining = Long.parseLong(response.getHeaders().get("X-RateLimit-Custom")); assertThat(remaining).isLessThan(previousRemaining); previousRemaining = remaining; // TODO: assert additional headers From 3f0b129ce713ba2b6fad0a1a459214674054db16 Mon Sep 17 00:00:00 2001 From: spencergibb Date: Wed, 4 Dec 2024 13:43:21 -0500 Subject: [PATCH 10/11] Disables test --- .../cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java index 29a914dd9d..9181ce031f 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java @@ -22,6 +22,7 @@ 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 void redisRateLimiterWorks() throws Exception { } @Test + @DisabledIfEnvironmentVariable(named = "GITHUB_ACTIONS", matches = "true") public void redisRateLimiterWorksForMultipleRoutes() throws Exception { String id = UUID.randomUUID().toString(); From 4bcad5996fbd5efbafa2913870021dab16377bec Mon Sep 17 00:00:00 2001 From: spencergibb Date: Fri, 6 Dec 2024 09:30:14 -0500 Subject: [PATCH 11/11] Adds documentation and refillTokens property --- .../requestratelimiter-factory.adoc | 88 +++++++++++++++++-- .../filter/ratelimit/Bucket4jRateLimiter.java | 36 +++++--- .../ratelimit/Bucket4jRateLimiterTests.java | 5 +- 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway/gatewayfilter-factories/requestratelimiter-factory.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway/gatewayfilter-factories/requestratelimiter-factory.adoc index 6f0044d4bf..6a96ddc383 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway/gatewayfilter-factories/requestratelimiter-factory.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway/gatewayfilter-factories/requestratelimiter-factory.adoc @@ -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] +---- + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + com.bucket4j + bucket4j_jdk17-caffeine + ${bucket4j.version} + +---- + +First a bean of type `io.github.bucket4j.distributed.proxy.AsyncProxyMananger` needs to be created. .Config.java [source,java] ---- @Bean -KeyResolver userKeyResolver() { - return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("user")); +AsyncProxyManager caffeineProxyManager() { + Caffeine 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`. diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java index 49f51d7e78..b32a47c1b5 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiter.java @@ -114,10 +114,12 @@ public static class Config { private static final Function 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(config.getCapacity(), config.getPeriod()); - case INTERVALLY -> bandwidth.refillIntervally(config.getCapacity(), config.getPeriod()); - case INTERVALLY_ALIGNED -> bandwidth.refillIntervallyAligned(config.getCapacity(), config.getPeriod(), + case GREEDY -> bandwidth.refillGreedy(refillTokens, config.getRefillPeriod()); + case INTERVALLY -> bandwidth.refillIntervally(refillTokens, config.getRefillPeriod()); + case INTERVALLY_ALIGNED -> bandwidth.refillIntervallyAligned(refillTokens, config.getRefillPeriod(), config.getTimeOfFirstRefill()); }; @@ -132,10 +134,12 @@ public static class Config { String headerName = DEFAULT_HEADER_NAME; - Duration period; + Duration refillPeriod; RefillStyle refillStyle = RefillStyle.GREEDY; + Long refillTokens; + long requestedTokens = 1; // for RefillStyle.INTERVALLY_ALIGNED @@ -181,12 +185,12 @@ public Config setHeaderName(String headerName) { return this; } - public Duration getPeriod() { - return period; + public Duration getRefillPeriod() { + return refillPeriod; } - public Config setPeriod(Duration period) { - this.period = period; + public Config setRefillPeriod(Duration refillPeriod) { + this.refillPeriod = refillPeriod; return this; } @@ -199,6 +203,15 @@ public Config setRefillStyle(RefillStyle refillStyle) { return this; } + public Long getRefillTokens() { + return refillTokens; + } + + public Config setRefillTokens(Long refillTokens) { + this.refillTokens = refillTokens; + return this; + } + public long getRequestedTokens() { return requestedTokens; } @@ -220,8 +233,9 @@ public Config setTimeOfFirstRefill(Instant timeOfFirstRefill) { public String toString() { return new ToStringCreator(this).append("capacity", capacity) .append("headerName", headerName) - .append("period", period) + .append("refillPeriod", refillPeriod) .append("refillStyle", refillStyle) + .append("refillTokens", refillTokens) .append("requestedTokens", requestedTokens) .append("timeOfFirstRefill", timeOfFirstRefill) .toString(); @@ -237,13 +251,13 @@ public enum RefillStyle { GREEDY, /** - * Intervally, in opposite to greedy, waits until the whole period has elapsed + * Intervally, in opposite to greedy, waits until the whole refillPeriod has elapsed * before refilling tokens. */ INTERVALLY, /** - * IntervallyAligned, like Intervally, but with an specified first refill time. + * IntervallyAligned, like Intervally, but with a specified first refill time. */ INTERVALLY_ALIGNED; diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java index 9cf0d2e3df..f262300bf9 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/Bucket4jRateLimiterTests.java @@ -64,7 +64,6 @@ public void bucket4jRateLimiterWorks(RefillStyle refillStyle) throws Exception { String id = UUID.randomUUID().toString(); long capacity = 10; - // int burstCapacity = 2 * capacity; int requestedTokens = 1; String routeId = "myroute"; @@ -73,7 +72,7 @@ public void bucket4jRateLimiterWorks(RefillStyle refillStyle) throws Exception { new Bucket4jRateLimiter.Config().setRefillStyle(refillStyle) .setHeaderName("X-RateLimit-Custom") .setCapacity(capacity) - .setPeriod(Duration.ofSeconds(1))); + .setRefillPeriod(Duration.ofSeconds(1))); checkLimitEnforced(id, capacity, requestedTokens, routeId); } @@ -89,7 +88,7 @@ public void bucket4jRateLimiterIsAllowedFalseWorks() throws Exception { rateLimiter.getConfig() .put(routeId, new Bucket4jRateLimiter.Config().setCapacity(capacity) - .setPeriod(Duration.ofSeconds(1)) + .setRefillPeriod(Duration.ofSeconds(1)) .setRequestedTokens(requestedTokens)); Response response = rateLimiter.isAllowed(routeId, id).block();