Skip to content

Commit 736356d

Browse files
committed
[UNDERTOW-2537] Implement bit shift window rate limiter and limiter handler
1 parent 6a4985f commit 736356d

File tree

7 files changed

+712
-0
lines changed

7 files changed

+712
-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
@@ -54,6 +54,8 @@
5454
import io.undertow.server.handlers.builder.PredicatedHandler;
5555
import io.undertow.server.handlers.proxy.ProxyClient;
5656
import io.undertow.server.handlers.proxy.ProxyHandler;
57+
import io.undertow.server.handlers.ratelimit.RateLimiter;
58+
import io.undertow.server.handlers.ratelimit.RateLimitingHandler;
5759
import io.undertow.server.handlers.resource.ResourceHandler;
5860
import io.undertow.server.handlers.resource.ResourceManager;
5961
import io.undertow.server.handlers.sse.ServerSentEventConnectionCallback;
@@ -624,6 +626,30 @@ public static HostHeaderHandler hostHeaderHandler(HttpHandler next) {
624626
return new HostHeaderHandler(next);
625627
}
626628

629+
/**
630+
* Create Rate limiting handler with default status and error message
631+
* @param limiter - implementation of RateLimiter which will factor information to keep count of incoming requests.
632+
* @param next - next handler in chain, which will be invoked if request number does not hit limit
633+
* @return
634+
*/
635+
public static RateLimitingHandler rateLimitingHandler(final RateLimiter limiter, final HttpHandler next) {
636+
return new RateLimitingHandler(next, limiter);
637+
}
638+
639+
/**
640+
* Create Rate limiting handler with custom status and error message
641+
* @param limiter - implementation of RateLimiter which will factor information to keep count of incoming requests.
642+
* @param next - next handler in chain, which will be invoked if request number does not hit limit
643+
* @param statusMessage - message that will be set as response status line
644+
* @param statusCode - specific status code that will be sent
645+
* @param enforced - if rejection is enforced or not.
646+
* @param signalLimit - if handler should send header back in response
647+
* @return
648+
*/
649+
public static RateLimitingHandler rateLimitingHandler(final RateLimiter limiter, final String statusMessage, final int statusCode, final boolean enforced, final boolean signalLimit, final HttpHandler next) {
650+
return new RateLimitingHandler(next, limiter, statusMessage, statusCode, enforced, signalLimit);
651+
}
652+
627653
private Handlers() {
628654

629655
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,4 +492,13 @@ void nodeConfigCreated(URI connectionURI, String balancer, String domain, String
492492
@LogMessage(level = WARN)
493493
@Message(id = 5108, value = "Configuration option is no longer supported: %s.")
494494
void configurationNotSupported(String string);
495+
496+
@LogMessage(level = WARN)
497+
@Message(id = 5109, 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'.")
498+
void exchangeExceedsRequestRateLimit(String requestTargetURI, String clientIPAddress, int rateLimit, int windowDuration, int timeToWindowSlide, boolean enforced);
499+
500+
@LogMessage(level = WARN)
501+
@Message(id = 5110, value = "Failed to resolve proper address for request to '%s', falling back to '%s'.")
502+
void rateLimitFailedToGetProperAddress(String requestTargetURI, String clientIPAddress);
503+
495504
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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 both 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+
if(this.requestLimit == TICK_BORDER) {
78+
this.requestLimit--;//2M, but needs to be covered. Check {@link RateLimiter#getRequestLimit()}
79+
}
80+
81+
}
82+
83+
@Override
84+
public int getWindowDuration() {
85+
return this.windowDuration;
86+
}
87+
88+
@Override
89+
public int getRequestLimit() {
90+
return this.requestLimit;
91+
}
92+
93+
@Override
94+
public int timeToWindowSlide(final HttpServerExchange exchange) {
95+
// window is common so we ignore parameter.
96+
// nextPrefix->miliseconds-currentMilis/1000->s;
97+
final long currentMilis = System.currentTimeMillis();
98+
return Math.toIntExact((((generatePrefix(currentMilis) + 1)<<bitsToShift) - currentMilis)/1000);
99+
}
100+
101+
@Override
102+
public int increment(final HttpServerExchange exchange) {
103+
evictionCheck(exchange);
104+
105+
final String ipAddress = getIPAddress(exchange, true);
106+
final String key = generateKey(ipAddress);
107+
AtomicInteger ai = requestCounter.computeIfAbsent(key, v -> new AtomicInteger());
108+
return ai.accumulateAndGet(1, (value, upTick) -> {
109+
// JIC
110+
if (value < TICK_BORDER) {
111+
return value + upTick; //currentValue + 1(upTick)
112+
} else {
113+
return Integer.MAX_VALUE; //cap at Integer.MAX_VALUE, check RateLimi
114+
}
115+
});
116+
}
117+
118+
@Override
119+
public int current(final HttpServerExchange exchange) {
120+
final String ipAddress = getIPAddress(exchange, true);
121+
final String key = generateKey(ipAddress);
122+
AtomicInteger entry = requestCounter.get(key);
123+
if(entry != null) {
124+
return entry.get();
125+
} else {
126+
return -1;
127+
}
128+
}
129+
130+
private String getIPAddress(final HttpServerExchange exchange, final boolean warn) {
131+
final InetSocketAddress sourceAddress = exchange.getSourceAddress();
132+
InetAddress address = sourceAddress.getAddress();
133+
if (address == null) {
134+
// this can happen when we have an unresolved X-forwarded-for address
135+
// in this case we just return the IP of the balancer
136+
//TODO: this needs impr
137+
address = ((InetSocketAddress) exchange.getConnection().getPeerAddress()).getAddress();
138+
if(warn) {
139+
UndertowLogger.REQUEST_LOGGER.rateLimitFailedToGetProperAddress(exchange.getRequestURI(), address.getHostAddress());
140+
}
141+
}
142+
return address.getHostAddress();
143+
}
144+
145+
@Override
146+
public String getLimiterID() {
147+
return "bit-shift-window";
148+
}
149+
150+
@Override
151+
public RateLimitUnit getUnit() {
152+
return RateLimitUnit.REQUEST;
153+
}
154+
155+
@Override
156+
public HttpServerExchange generateKey(HttpServerExchange e) {
157+
return e;
158+
}
159+
160+
private void evictionCheck(HttpServerExchange exchange) {
161+
// we need to parasite on IO threads for eviction.
162+
XnioExecutor.Key key = this.evictionKey;
163+
if (key == null) {
164+
this.evictionKey = WorkerUtils.executeAfter(exchange.getIoThread(), new Runnable() {
165+
@Override
166+
public void run() {
167+
evictionKey = null;
168+
evictOldWindow();
169+
}
170+
}, this.windowDuration, TimeUnit.SECONDS);
171+
}
172+
}
173+
174+
private void evictOldWindow() {
175+
// evict entries that are not 'current' or 'current+1' - just in case eviction starts in one window and finish in next.
176+
// in such case 'current' will become stale already and will be evicted on next call. Thats fine.
177+
// prefix + 1 will translate into bitshift ++
178+
final long currentPrefix = generatePrefix();
179+
final String current = String.valueOf(currentPrefix);
180+
final String next = String.valueOf(currentPrefix + 1);
181+
182+
ConcurrentHashMap.KeySetView<String, AtomicInteger> keys = requestCounter.keySet();
183+
// remove obsolete keys
184+
keys.removeIf(k -> !k.startsWith(current) && !k.startsWith(next));
185+
186+
}
187+
188+
private String generateKey(String ipAddress) {
189+
return generatePrefix() + PREFIX_SEPARATOR + ipAddress;
190+
}
191+
192+
private long generatePrefix(long timeMilis) {
193+
return timeMilis >> this.bitsToShift;
194+
}
195+
196+
private long generatePrefix() {
197+
return generatePrefix(System.currentTimeMillis());
198+
}
199+
200+
private int nextPowerOf2(final int v) {
201+
// this will return closest one bit, for 19, it will be 16. << 1 to get next highest
202+
final int higherOneBitValue = Integer.highestOneBit(v);
203+
if (v == higherOneBitValue) {
204+
return higherOneBitValue;
205+
} else {
206+
return higherOneBitValue << 1;
207+
}
208+
}
209+
210+
private int determineBitShiftForDuration(final int duration) {
211+
// duration to milliseconds.
212+
final int nextP2 = nextPowerOf2(duration * 1000);
213+
// since its pure next power of 2, it has leading 1 and trailing zeros, which are equal to bit shift
214+
return Integer.numberOfTrailingZeros(nextP2);
215+
}
216+
217+
}
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)