This is an automated email from the ASF dual-hosted git repository.
markt pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/main by this push:
new c1e50fb357 Add an exact rate limit filter.
c1e50fb357 is described below
commit c1e50fb3574ab6aeb02d1346fc0c5ff7b1f9ca08
Author: Mark Thomas <[email protected]>
AuthorDate: Fri Mar 7 14:55:22 2025 +0000
Add an exact rate limit filter.
Based on PR #794 by Chenjp
---
.../apache/catalina/filters/RateLimitFilter.java | 7 +-
.../org/apache/catalina/util/ExactRateLimiter.java | 68 +++++++
java/org/apache/catalina/util/FastRateLimiter.java | 1 +
java/org/apache/catalina/util/RateLimiterBase.java | 10 +
.../catalina/util/TimeBucketCounterBase.java | 3 -
.../TestRateLimitFilterWithExactRateLimiter.java | 211 +++++++++++++++++++++
webapps/docs/changelog.xml | 7 +
webapps/docs/config/filter.xml | 30 ++-
8 files changed, 323 insertions(+), 14 deletions(-)
diff --git a/java/org/apache/catalina/filters/RateLimitFilter.java
b/java/org/apache/catalina/filters/RateLimitFilter.java
index 8e07ca8182..80315948bb 100644
--- a/java/org/apache/catalina/filters/RateLimitFilter.java
+++ b/java/org/apache/catalina/filters/RateLimitFilter.java
@@ -47,8 +47,11 @@ import org.apache.tomcat.util.res.StringManager;
* so it converts some configured values to more efficient values. For
example, a configuration of a 60 seconds time
* bucket is converted to 65.536 seconds. That allows for very fast bucket
calculation using bit shift arithmetic. In
* order to remain true to the user intent, the configured number of requests
is then multiplied by the same ratio, so a
- * configuration of 100 Requests per 60 seconds, has the real values of 109
Requests per 65 seconds. You can specify a
- * different class as long as it implements the
<code>org.apache.catalina.util.RateLimiter</code> interface.
+ * configuration of 100 Requests per 60 seconds, has the real values of 109
Requests per 65 seconds. An alternative
+ * implementation, <code>org.apache.catalina.util.ExactRateLimiter</code>, is
intended to provide a less efficient but
+ * more accurate control, whose effective duration in seconds and number of
requests configuration are consist with the
+ * user declared. You can specify a different class as long as it implements
the
+ * <code>org.apache.catalina.util.RateLimiter</code> interface.
* </p>
* <p>
* It is common to set up different restrictions for different URIs. For
example, a login page or authentication script
diff --git a/java/org/apache/catalina/util/ExactRateLimiter.java
b/java/org/apache/catalina/util/ExactRateLimiter.java
new file mode 100644
index 0000000000..a1b8c9190f
--- /dev/null
+++ b/java/org/apache/catalina/util/ExactRateLimiter.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.apache.catalina.util;
+
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * A RateLimiter that compromises efficiency for accuracy in order to provide
exact rate limiting.
+ */
+public class ExactRateLimiter extends RateLimiterBase {
+
+ @Override
+ protected String getDefaultPolicyName() {
+ return "exact";
+ }
+
+
+ @Override
+ protected TimeBucketCounterBase newCounterInstance(int duration,
ScheduledExecutorService executorService) {
+ return new ExactTimeBucketCounter(duration, executorService);
+ }
+
+
+ /**
+ * An accurate counter with exact bucket index, but slightly less
efficient than another fast counter provided with
+ * the {@link FastRateLimiter}.
+ */
+ class ExactTimeBucketCounter extends TimeBucketCounterBase {
+
+ ExactTimeBucketCounter(int bucketDuration, ScheduledExecutorService
executorService) {
+ super(bucketDuration, executorService);
+ }
+
+ @Override
+ public long getBucketIndex(long timestamp) {
+ return (timestamp / 1000) / getBucketDuration();
+ }
+
+ @Override
+ public double getRatio() {
+ // Actual value is exactly the same as declared
+ return 1.0d;
+ }
+
+ @Override
+ public long getMillisUntilNextBucket() {
+ long millis = System.currentTimeMillis();
+
+ long nextTimeBucketMillis = (getBucketIndex(millis) + 1) *
getBucketDuration() * 1000;
+ long delta = nextTimeBucketMillis - millis;
+ return delta;
+ }
+ }
+}
diff --git a/java/org/apache/catalina/util/FastRateLimiter.java
b/java/org/apache/catalina/util/FastRateLimiter.java
index 17544c5d28..dc7b9d5249 100644
--- a/java/org/apache/catalina/util/FastRateLimiter.java
+++ b/java/org/apache/catalina/util/FastRateLimiter.java
@@ -35,6 +35,7 @@ public class FastRateLimiter extends RateLimiterBase {
}
+ @Override
public TimeBucketCounter getBucketCounter() {
return (TimeBucketCounter) bucketCounter;
}
diff --git a/java/org/apache/catalina/util/RateLimiterBase.java
b/java/org/apache/catalina/util/RateLimiterBase.java
index 1f4c699462..d04c88836e 100644
--- a/java/org/apache/catalina/util/RateLimiterBase.java
+++ b/java/org/apache/catalina/util/RateLimiterBase.java
@@ -142,4 +142,14 @@ public abstract class RateLimiterBase implements
RateLimiter {
actualDuration = bucketCounter.getBucketDuration();
actualRequests = (int) Math.round(bucketCounter.getRatio() * requests);
}
+
+
+ /**
+ * Returns the internal instance of {@link TimeBucketCounterBase}.
+ *
+ * @return instance of {@link TimeBucketCounterBase}
+ */
+ public TimeBucketCounterBase getBucketCounter() {
+ return bucketCounter;
+ }
}
diff --git a/java/org/apache/catalina/util/TimeBucketCounterBase.java
b/java/org/apache/catalina/util/TimeBucketCounterBase.java
index b2b0bbee09..4679a41f0a 100644
--- a/java/org/apache/catalina/util/TimeBucketCounterBase.java
+++ b/java/org/apache/catalina/util/TimeBucketCounterBase.java
@@ -156,10 +156,7 @@ public abstract class TimeBucketCounterBase {
* <strong>WARNING:</strong> This method is used for test purpose.
*
* @return the number of milliseconds until the next bucket
- *
- * @deprecated Will be made package private in Tomcat 12 onwards.
*/
- @Deprecated
public abstract long getMillisUntilNextBucket();
diff --git
a/test/org/apache/catalina/filters/TestRateLimitFilterWithExactRateLimiter.java
b/test/org/apache/catalina/filters/TestRateLimitFilterWithExactRateLimiter.java
new file mode 100644
index 0000000000..de6787844b
--- /dev/null
+++
b/test/org/apache/catalina/filters/TestRateLimitFilterWithExactRateLimiter.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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
+ *
+ * http://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.apache.catalina.filters;
+
+import java.io.IOException;
+
+import jakarta.servlet.FilterChain;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.filters.TestRemoteIpFilter.MockFilterChain;
+import org.apache.catalina.filters.TestRemoteIpFilter.MockHttpServletRequest;
+import org.apache.catalina.startup.Tomcat;
+import org.apache.catalina.startup.TomcatBaseTest;
+import org.apache.catalina.util.ExactRateLimiter;
+import org.apache.tomcat.unittest.TesterResponse;
+import org.apache.tomcat.util.descriptor.web.FilterDef;
+import org.apache.tomcat.util.descriptor.web.FilterMap;
+
+public class TestRateLimitFilterWithExactRateLimiter extends TomcatBaseTest {
+ private void testRateLimitWith1Clients(boolean exposeHeaders, boolean
enforce) throws Exception {
+
+ int bucketRequests = 40;
+ int bucketDuration = 4;
+
+ FilterDef filterDef = new FilterDef();
+ filterDef.addInitParameter("bucketRequests",
String.valueOf(bucketRequests));
+ filterDef.addInitParameter("bucketDuration",
String.valueOf(bucketDuration));
+ filterDef.addInitParameter("enforce", String.valueOf(enforce));
+ filterDef.addInitParameter("exposeHeaders",
String.valueOf(exposeHeaders));
+ filterDef.addInitParameter("rateLimitClassName",
"org.apache.catalina.util.ExactRateLimiter");
+
+ Tomcat tomcat = getTomcatInstance();
+ Context root = tomcat.addContext("", TEMP_DIR);
+
+ MockFilterChain filterChain = new MockFilterChain();
+ RateLimitFilter rateLimitFilter = testRateLimitFilter(filterDef, root);
+ tomcat.start();
+
+ ExactRateLimiter exactRateLimiter = (ExactRateLimiter)
rateLimitFilter.rateLimiter;
+
+ int allowedRequests = exactRateLimiter.getRequests();
+ long sleepTime =
exactRateLimiter.getBucketCounter().getMillisUntilNextBucket();
+ System.out.printf("Sleeping %d millis for the next time bucket to
start\n", Long.valueOf(sleepTime));
+ Thread.sleep(sleepTime);
+
+ TestClient tc1 = new TestClient(rateLimitFilter, filterChain,
"10.20.20.5", 50, 5); // TPS: 5
+ TestClient tc2 = new TestClient(rateLimitFilter, filterChain,
"10.20.20.10", 100, 10); // TPS: 10
+
+ TestClient tc3 = new TestClient(rateLimitFilter, filterChain,
"10.20.20.20", 200, 20); // TPS: 20
+ TestClient tc4 = new TestClient(rateLimitFilter, filterChain,
"10.20.20.40", 400, 40); // TPS: 40
+ tc1.join();
+ tc2.join();
+ tc3.join();
+ tc4.join();
+ Assert.assertEquals(200, tc1.results[24]); // only 25 requests made in
5 seconds, all allowed
+
+ Assert.assertEquals(200, tc2.results[49]); // only 50 requests made in
5 seconds, all allowed
+
+ Assert.assertEquals(200, tc3.results[39]); // first allowedRequests
allowed
+
+ if (enforce) {
+ Assert.assertEquals(429, tc3.results[allowedRequests]); //
subsequent requests dropped
+ } else {
+ Assert.assertEquals(200, tc3.results[allowedRequests]);
+ }
+
+ Assert.assertEquals(200, tc4.results[allowedRequests - 1]); // first
allowedRequests allowed
+
+ if (enforce) {
+ Assert.assertEquals(429, tc4.results[allowedRequests]); //
subsequent requests dropped
+ } else {
+ Assert.assertEquals(200, tc4.results[allowedRequests]);
+ }
+
+ if (exposeHeaders) {
+ Assert.assertTrue(tc3.rlpHeader[24].contains("q=" +
allowedRequests));
+ Assert.assertTrue(tc3.rlpHeader[allowedRequests].contains("q=" +
allowedRequests));
+ if (enforce) {
+ Assert.assertTrue(tc3.rlHeader[24].contains("r="));
+ Assert.assertFalse(tc3.rlHeader[24].contains("r=0"));
+
Assert.assertTrue(tc3.rlHeader[allowedRequests].contains("r=0"));
+ }
+ } else {
+ Assert.assertTrue(tc3.rlpHeader[24] == null);
+ Assert.assertTrue(tc3.rlHeader[24] == null);
+ Assert.assertTrue(tc3.rlpHeader[allowedRequests] == null);
+ Assert.assertTrue(tc3.rlHeader[allowedRequests] == null);
+ }
+ tomcat.stop();
+ }
+
+ @Test
+ public void testExposeHeaderAndRerferenceRateLimitWith4Clients() throws
Exception {
+ testRateLimitWith1Clients(true, false);
+ }
+
+ @Test
+ public void testUnexposeHeaderAndRerferenceRateLimitWith4Clients() throws
Exception {
+ testRateLimitWith1Clients(false, false);
+ }
+
+ @Test
+ public void testExposeHeaderAndEnforceRateLimitWith4Clients() throws
Exception {
+ testRateLimitWith1Clients(true, true);
+ }
+
+ @Test
+ public void testUnexposeHeaderAndEnforceRateLimitWith4Clients() throws
Exception {
+ testRateLimitWith1Clients(false, true);
+ }
+
+ private RateLimitFilter testRateLimitFilter(FilterDef filterDef, Context
root) {
+
+ RateLimitFilter rateLimitFilter = new RateLimitFilter();
+ filterDef.setFilterClass(RateLimitFilter.class.getName());
+ filterDef.setFilter(rateLimitFilter);
+ filterDef.setFilterName(RateLimitFilter.class.getName());
+ root.addFilterDef(filterDef);
+
+ FilterMap filterMap = new FilterMap();
+ filterMap.setFilterName(RateLimitFilter.class.getName());
+ filterMap.addURLPatternDecoded("*");
+ root.addFilterMap(filterMap);
+
+ return rateLimitFilter;
+ }
+
+ static class TestClient extends Thread {
+ RateLimitFilter filter;
+ FilterChain filterChain;
+ String ip;
+
+ int requests;
+ int sleep;
+
+ int[] results;
+ volatile String[] rlpHeader;
+ volatile String[] rlHeader;
+
+ TestClient(RateLimitFilter filter, FilterChain filterChain, String ip,
int requests, int rps) {
+ this.filter = filter;
+ this.filterChain = filterChain;
+ this.ip = ip;
+ this.requests = requests;
+ this.sleep = 1000 / rps;
+ this.results = new int[requests];
+ this.rlpHeader = new String[requests];
+ this.rlHeader = new String[requests];
+ super.setDaemon(true);
+ super.start();
+ }
+
+ @Override
+ public void run() {
+ try {
+ for (int i = 0; i < requests; i++) {
+ MockHttpServletRequest request = new
MockHttpServletRequest();
+ request.setRemoteAddr(ip);
+ TesterResponse response = new TesterResponseWithStatus();
+ response.setRequest(request);
+ filter.doFilter(request, response, filterChain);
+ results[i] = response.getStatus();
+
+ rlpHeader[i] =
response.getHeader(RateLimitFilter.HEADER_RATE_LIMIT_POLICY);
+ rlHeader[i] =
response.getHeader(RateLimitFilter.HEADER_RATE_LIMIT);
+
+ if (results[i] != 200) {
+ break;
+ }
+ sleep(sleep);
+ }
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ }
+ }
+
+ static class TesterResponseWithStatus extends TesterResponse {
+
+ int status = 200;
+ String message = "OK";
+
+ @Override
+ public void sendError(int status, String message) throws IOException {
+ this.status = status;
+ this.message = message;
+ }
+
+ @Override
+ public int getStatus() {
+ return status;
+ }
+ }
+}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 39285b90a7..b1cfac4946 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -154,6 +154,13 @@
Return 400 if the amount of content sent for a partial PUT is
inconsistent with the range that was specified. (remm)
</fix>
+ <add>
+ Add a new <code>RateLimiter</code> implementation,
+ <code>org.apache.catalina.util.ExactRateLimiter</code>, that can be
used
+ with <code>org.apache.catalina.filters.RateLimitFilter</code> to
provide
+ rate limit based on the exact values configured. Based on pull request
+ <pr>794</pr> by Chenjp. (markt)
+ </add>
</changelog>
</subsection>
<subsection name="Coyote">
diff --git a/webapps/docs/config/filter.xml b/webapps/docs/config/filter.xml
index 5a879727ab..c69c6d1cbb 100644
--- a/webapps/docs/config/filter.xml
+++ b/webapps/docs/config/filter.xml
@@ -968,13 +968,21 @@ FINE: Request "/docs/config/manager.html" with response
status "200"
from that IP are dropped with a "429 Too many requests" response
until the bucket time ends and a new bucket starts.</p>
- <p>The filter is optimized for efficiency and low overhead, so it converts
- some configured values to more efficient values. For example, a
configuration
- of a 60 seconds time bucket is converted to 65.536 seconds. That allows
- for very fast bucket calculation using bit shift arithmetic. In order to
remain
- true to the user intent, the configured number of requests is then
multiplied
- by the same ratio, so a configuration of 100 Requests per 60 seconds, has
the
- real values of 109 Requests per 65 seconds.</p>
+ <p>The RateLimiter implementation can be set via the
<code>rateLimitClassName</code>
+ init param. The default implementation,
+ <code>org.apache.catalina.util.FastRateLimiter</code>, is optimized for
+ efficiency and low overhead so it converts some configured values to more
+ efficient values. For example, a configuration of a 60 seconds time bucket
+ is converted to 65.536 seconds. That allows for very fast bucket
calculation
+ using bit shift arithmetic. In order to remain true to the user intent, the
+ configured number of requests is then multiplied by the same ratio, so a
+ configuration of 100 Requests per 60 seconds, has the real values of 109
+ Requests per 65 seconds. An alternative implementation,
+ <code>org.apache.catalina.util.ExactRateLimiter</code>, is intended to
+ provide a less efficient but more accurate control, whose effective
duration
+ in seconds and number of requests allowed are consist with the configured
+ values. You can specify a different class as long as it implements the
+ <code>org.apache.catalina.util.RateLimiter</code> interface.</p>
<p>It is common to set up different restrictions for different URIs.
For example, a login page or authentication script is typically expected
@@ -1046,8 +1054,12 @@ FINE: Request "/docs/config/manager.html" with response
status "200"
</attribute>
<attribute name="rateLimitClassName" required="false">
- <p>The full class name of an implementation of the RateLimiter
interface.
- Default is "org.apache.catalina.util.FastRateLimiter".</p>
+ <p>The full class name of an implementation of the RateLimiter
+ interface. Default is
+ "org.apache.catalina.util.FastRateLimiter", which is
optimized
+ for efficiency. If you need exact rate limiting and can accept a small
+ decrease in efficiency, you can use
+ "org.apache.catalina.util.ExactRateLimiter" instead.</p>
</attribute>
<attribute name="statusCode" required="false">
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]