Skip to content

Commit 3d6c68f

Browse files
committed
[UNDERTOW-2537] Implement bit shift window rate limiter and limiter handler
1 parent 0ea9ca3 commit 3d6c68f

File tree

7 files changed

+682
-0
lines changed

7 files changed

+682
-0
lines changed

core/src/main/java/io/undertow/Handlers.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
import io.undertow.server.handlers.builder.PredicatedHandler;
5454
import io.undertow.server.handlers.proxy.ProxyClient;
5555
import io.undertow.server.handlers.proxy.ProxyHandler;
56+
import io.undertow.server.handlers.ratelimit.RateLimiter;
57+
import io.undertow.server.handlers.ratelimit.RateLimitingHandler;
5658
import io.undertow.server.handlers.resource.ResourceHandler;
5759
import io.undertow.server.handlers.resource.ResourceManager;
5860
import io.undertow.server.handlers.sse.ServerSentEventConnectionCallback;
@@ -612,6 +614,30 @@ public static LearningPushHandler learningPushHandler(int maxEntries, HttpHandle
612614
return new LearningPushHandler(maxEntries, -1, next);
613615
}
614616

617+
/**
618+
* Create Rate limiting handler with default status and error message
619+
* @param limiter - implementation of RateLimiter which will factor information to keep count of incoming requests.
620+
* @param next - next handler in chain, which will be invoked if request number does not hit limit
621+
* @return
622+
*/
623+
public static RateLimitingHandler rateLimitingHandler(final RateLimiter limiter, final HttpHandler next) {
624+
return new RateLimitingHandler(next, limiter);
625+
}
626+
627+
/**
628+
* Create Rate limiting handler with custom status and error message
629+
* @param limiter - implementation of RateLimiter which will factor information to keep count of incoming requests.
630+
* @param next - next handler in chain, which will be invoked if request number does not hit limit
631+
* @param statusMessage - message that will be set as response status line
632+
* @param statusCode - specific status code that will be sent
633+
* @param enforced - if rejection is enforced or not.
634+
* @param signalLimit - if handler should send header back in response
635+
* @return
636+
*/
637+
public static RateLimitingHandler rateLimitingHandler(final RateLimiter limiter, final String statusMessage, final int statusCode, final boolean enforced, final boolean signalLimit, final HttpHandler next) {
638+
return new RateLimitingHandler(next, limiter, statusMessage, statusCode, enforced, signalLimit);
639+
}
640+
615641
private Handlers() {
616642

617643
}

core/src/main/java/io/undertow/UndertowLogger.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,4 +488,13 @@ void nodeConfigCreated(URI connectionURI, String balancer, String domain, String
488488
@LogMessage(level = WARN)
489489
@Message(id = 5107, value = "Failed to set web socket timeout.")
490490
void failedToSetWSTimeout(@Cause Exception e);
491+
492+
@LogMessage(level = WARN)
493+
@Message(id = 5108, value = "Request to '%s' from '%s' exceed rate limit of '%s' in time window of '%s'. Window will reset in '%s'. Rate limit is enforced '%s'.")
494+
void exchangeExceedsRequestRateLimit(String requestTargetURI, String clientIPAddress, int rateLimit, int windowDuration, int timeToWindowSlide, boolean enforced);
495+
496+
@LogMessage(level = WARN)
497+
@Message(id = 5109, value = "Failed to resolve proper address for request to '%s', falling back to '%s'.")
498+
void rateLimitFailedToGetProperAddress(String requestTargetURI, String clientIPAddress);
499+
491500
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*
2+
* JBoss, Home of Professional Open Source.
3+
* Copyright 2025 Red Hat, Inc., and individual contributors
4+
* as indicated by the @author tags.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package io.undertow.server.handlers.ratelimit;
19+
20+
import java.net.InetAddress;
21+
import java.net.InetSocketAddress;
22+
import java.util.concurrent.ConcurrentHashMap;
23+
import java.util.concurrent.TimeUnit;
24+
import java.util.concurrent.atomic.AtomicInteger;
25+
26+
import org.xnio.XnioExecutor;
27+
28+
import io.undertow.UndertowLogger;
29+
import io.undertow.server.HttpServerExchange;
30+
import io.undertow.util.WorkerUtils;
31+
32+
/**
33+
* This is bit shift implementation of sliding window. Both rate and duration are converted to next 2^n in order to simply
34+
* bitshift values, rather than perform 10 based math and convert back into 2 base representation. This implementation of
35+
* {@link RateLimiter} has single, common window for all entries. For which keys are computed with bit shifting ops. This is not
36+
* precise, but has very low performance impact.
37+
*/
38+
public class BitShiftSingleWindowRateLimiter implements RateLimiter<HttpServerExchange> {
39+
// Single window to make it less resource hungry and simpler for first iteration.
40+
private int windowDuration; // duration in seconds.
41+
private int requestLimit;
42+
private volatile XnioExecutor.Key evictionKey;
43+
// numbers of bits to shift. This will be used to determine prefix for 'requestCounter'; Its based on duration next power of
44+
// 2;
45+
private int bitsToShift;
46+
// This map will store entries under key == prefix-IPAddress. where prefix is
47+
// "major" part of timestamp
48+
// Prefix math:
49+
// 10000 --> 10
50+
// 10001 --> 10
51+
// Next: 10+1 --> 11000
52+
// In other words as long as bitsToShift wont cover ++, prefix will remain constant and allow fast and predictable
53+
// calculation of key.
54+
// Any key that does not start with prefix is outdated(window slid over it).
55+
// NOTE: this can be improved with Long as key and having upper store ~tstamp and lower having pure byte[] representation
56+
// of IP. Rest of logic would remain the same as for String key.
57+
// TODO: sanity check, this is IO thread, so no need for concurrent map and AtomicInteger ?
58+
private ConcurrentHashMap<String, AtomicInteger> requestCounter = new ConcurrentHashMap<String, AtomicInteger>(5000);
59+
private static final String PREFIX_SEPARATOR = "-";
60+
private static final int TICK_BORDER = Integer.MAX_VALUE - 1;
61+
/**
62+
* Create bit shift limiter. Implementation will adjust values of bot windowDuration and requestLimit, to adjust to next ^2.
63+
* Meaning duration of 33, will become 64. requestLimit will be adjusted to reflect this "stretch".
64+
*
65+
* @param windowDuration
66+
* @param requestLimit
67+
*/
68+
public BitShiftSingleWindowRateLimiter(final int windowDuration, final int requestLimit) {
69+
assert windowDuration > 0;
70+
assert requestLimit > 0;
71+
this.bitsToShift = determineBitShiftForDuration(windowDuration);
72+
this.windowDuration = Math.toIntExact(1L << this.bitsToShift) / 1000;
73+
// need to adjust requests, based on difference between bitshift duration and one that was passed here.
74+
// This is done to cover cases when nextP2 is not close to duration, for instance original duration 33s, will
75+
// switch to ~64, to have it work properly, we need to adjust limit as well.
76+
this.requestLimit = (int)(((float)this.windowDuration/windowDuration) * requestLimit);
77+
}
78+
79+
@Override
80+
public int getWindowDuration() {
81+
return this.windowDuration;
82+
}
83+
84+
@Override
85+
public int getRequestLimit() {
86+
return this.requestLimit;
87+
}
88+
89+
@Override
90+
public int timeToWindowSlide(final HttpServerExchange exchange) {
91+
// window is common so we ignore parameter.
92+
// nextPrefix->miliseconds-currentMilis/1000->s;
93+
final long currentMilis = System.currentTimeMillis();
94+
return Math.toIntExact((((generatePrefix(currentMilis) + 1)<<bitsToShift) - currentMilis)/1000);
95+
}
96+
97+
@Override
98+
public int increment(final HttpServerExchange exchange) {
99+
evictionCheck(exchange);
100+
101+
final String ipAddress = getIPAddress(exchange, true);
102+
final String key = generateKey(ipAddress);
103+
AtomicInteger ai = requestCounter.computeIfAbsent(key, v -> new AtomicInteger());
104+
return ai.accumulateAndGet(1, (value, upTick) -> {
105+
// JIC
106+
if (value < TICK_BORDER) {
107+
return value + upTick; //currentValue + 1(upTick)
108+
} else {
109+
return Integer.MAX_VALUE;
110+
}
111+
});
112+
}
113+
114+
@Override
115+
public int current(final HttpServerExchange exchange) {
116+
final String ipAddress = getIPAddress(exchange, true);
117+
final String key = generateKey(ipAddress);
118+
AtomicInteger entry = requestCounter.get(key);
119+
if(entry != null) {
120+
return entry.get();
121+
} else {
122+
return -1;
123+
}
124+
}
125+
126+
private String getIPAddress(final HttpServerExchange exchange, final boolean warn) {
127+
final InetSocketAddress sourceAddress = exchange.getSourceAddress();
128+
InetAddress address = sourceAddress.getAddress();
129+
if (address == null) {
130+
// this can happen when we have an unresolved X-forwarded-for address
131+
// in this case we just return the IP of the balancer
132+
//TODO: this needs impr
133+
address = ((InetSocketAddress) exchange.getConnection().getPeerAddress()).getAddress();
134+
if(warn) {
135+
UndertowLogger.REQUEST_LOGGER.rateLimitFailedToGetProperAddress(exchange.getRequestURI(), address.getHostAddress());
136+
}
137+
}
138+
return address.getHostAddress();
139+
}
140+
141+
@Override
142+
public String getLimiterID() {
143+
return "bit-shift-window";
144+
}
145+
146+
@Override
147+
public RateLimitUnit getUnit() {
148+
return RateLimitUnit.REQUEST;
149+
}
150+
151+
@Override
152+
public HttpServerExchange generateKey(HttpServerExchange e) {
153+
return e;
154+
}
155+
156+
private void evictionCheck(HttpServerExchange exchange) {
157+
// we need to parasite on IO threads for eviction.
158+
XnioExecutor.Key key = this.evictionKey;
159+
if (key == null) {
160+
this.evictionKey = WorkerUtils.executeAfter(exchange.getIoThread(), new Runnable() {
161+
@Override
162+
public void run() {
163+
evictionKey = null;
164+
evictOldWindow();
165+
}
166+
}, this.windowDuration, TimeUnit.SECONDS);
167+
}
168+
}
169+
170+
private void evictOldWindow() {
171+
// evict entries that are not 'current' or 'current+1' - just in case eviction starts in one window and finish in next.
172+
// in such case 'current' will become stale already and will be evicted on next call. Thats fine.
173+
// prefix + 1 will translate into bitshift ++
174+
final long currentPrefix = generatePrefix();
175+
final String current = String.valueOf(currentPrefix);
176+
final String next = String.valueOf(currentPrefix + 1);
177+
178+
ConcurrentHashMap.KeySetView<String, AtomicInteger> keys = requestCounter.keySet();
179+
// remove obsolete keys
180+
keys.removeIf(k -> !k.startsWith(current) && !k.startsWith(next));
181+
182+
}
183+
184+
private String generateKey(String ipAddress) {
185+
return generatePrefix() + PREFIX_SEPARATOR + ipAddress;
186+
}
187+
188+
private long generatePrefix(long timeMilis) {
189+
return timeMilis >> this.bitsToShift;
190+
}
191+
192+
private long generatePrefix() {
193+
return generatePrefix(System.currentTimeMillis());
194+
}
195+
196+
private int nextPowerOf2(final int v) {
197+
// this will return closest one bit, for 19, it will be 16. << 1 to get next highest
198+
final int higherOneBitValue = Integer.highestOneBit(v);
199+
if (v == higherOneBitValue) {
200+
return higherOneBitValue;
201+
} else {
202+
return higherOneBitValue << 1;
203+
}
204+
}
205+
206+
private int determineBitShiftForDuration(final int duration) {
207+
// duration to milliseconds.
208+
final int nextP2 = nextPowerOf2(duration * 1000);
209+
// since its pure next power of 2, it has leading 1 and trailing zeros, which are equal to bit shift
210+
return Integer.numberOfTrailingZeros(nextP2);
211+
}
212+
213+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* JBoss, Home of Professional Open Source.
3+
* Copyright 2025 Red Hat, Inc., and individual contributors
4+
* as indicated by the @author tags.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
package io.undertow.server.handlers.ratelimit;
19+
20+
public enum RateLimitUnit {
21+
//see: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ -> 10.3. RateLimit quota unit registry
22+
REQUEST("request"), CONTENT_BYTES("content-bytes"), CONCURRENT_REQUESTS("concurrent-requests");
23+
24+
private final String label;
25+
26+
RateLimitUnit(String label) {
27+
this.label = label;
28+
}
29+
30+
@Override
31+
public String toString() {
32+
return label;
33+
}
34+
}

0 commit comments

Comments
 (0)