diff --git a/core/src/main/java/io/undertow/Handlers.java b/core/src/main/java/io/undertow/Handlers.java index 4f2141693f..1bfd396f34 100644 --- a/core/src/main/java/io/undertow/Handlers.java +++ b/core/src/main/java/io/undertow/Handlers.java @@ -23,6 +23,7 @@ import io.undertow.predicate.PredicateParser; import io.undertow.predicate.PredicatesHandler; import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; import io.undertow.server.JvmRouteHandler; import io.undertow.server.RoutingHandler; import io.undertow.server.handlers.SetErrorHandler; @@ -53,6 +54,8 @@ import io.undertow.server.handlers.builder.PredicatedHandler; import io.undertow.server.handlers.proxy.ProxyClient; import io.undertow.server.handlers.proxy.ProxyHandler; +import io.undertow.server.handlers.ratelimit.RateLimiter; +import io.undertow.server.handlers.ratelimit.RateLimitingHandler; import io.undertow.server.handlers.resource.ResourceHandler; import io.undertow.server.handlers.resource.ResourceManager; import io.undertow.server.handlers.sse.ServerSentEventConnectionCallback; @@ -612,6 +615,30 @@ public static LearningPushHandler learningPushHandler(int maxEntries, HttpHandle return new LearningPushHandler(maxEntries, -1, next); } + /** + * Create Rate limiting handler with default status and error message + * @param limiter - implementation of RateLimiter which will factor information to keep count of incoming requests. + * @param next - next handler in chain, which will be invoked if request number does not hit limit + * @return + */ + public static RateLimitingHandler rateLimitingHandler(final RateLimiter limiter, final HttpHandler next) { + return new RateLimitingHandler(next, limiter); + } + + /** + * Create Rate limiting handler with custom status and error message + * @param limiter - implementation of RateLimiter which will factor information to keep count of incoming requests. + * @param next - next handler in chain, which will be invoked if request number does not hit limit + * @param statusMessage - message that will be set as response status line + * @param statusCode - specific status code that will be sent + * @param enforced - if rejection is enforced or not. + * @param signalLimit - if handler should send header back in response + * @return + */ + public static RateLimitingHandler rateLimitingHandler(final RateLimiter limiter, final String statusMessage, final int statusCode, final boolean enforced, final boolean signalLimit, final HttpHandler next) { + return new RateLimitingHandler(next, limiter, statusMessage, statusCode, enforced, signalLimit); + } + private Handlers() { } diff --git a/core/src/main/java/io/undertow/UndertowLogger.java b/core/src/main/java/io/undertow/UndertowLogger.java index 3bdfcc23ad..58150fc654 100644 --- a/core/src/main/java/io/undertow/UndertowLogger.java +++ b/core/src/main/java/io/undertow/UndertowLogger.java @@ -488,4 +488,13 @@ void nodeConfigCreated(URI connectionURI, String balancer, String domain, String @LogMessage(level = WARN) @Message(id = 5107, value = "Failed to set web socket timeout.") void failedToSetWSTimeout(@Cause Exception e); + + @LogMessage(level = WARN) + @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'.") + void exchangeExceedsRequestRateLimit(String requestTargetURI, String clientIPAddress, int rateLimit, int windowDuration, int timeToWindowSlide, boolean enforced); + + @LogMessage(level = WARN) + @Message(id = 5109, value = "Failed to resolve proper address for request to '%s', falling back to '%s'.") + void rateLimitFailedToGetProperAddress(String requestTargetURI, String clientIPAddress); + } \ No newline at end of file diff --git a/core/src/main/java/io/undertow/server/handlers/ratelimit/BitShiftSingleWindowRateLimiter.java b/core/src/main/java/io/undertow/server/handlers/ratelimit/BitShiftSingleWindowRateLimiter.java new file mode 100644 index 0000000000..951aa19b22 --- /dev/null +++ b/core/src/main/java/io/undertow/server/handlers/ratelimit/BitShiftSingleWindowRateLimiter.java @@ -0,0 +1,208 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2025 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * 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 + * + * 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 io.undertow.server.handlers.ratelimit; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.xnio.XnioExecutor; + +import io.undertow.UndertowLogger; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.WorkerUtils; + +/** + * This is bit shift implementation of sliding window. Both rate and duration are converted to next 2^n in order to simply + * bitshift values, rather than perform 10 based math and convert back into 2 base representation. This implementation of + * {@link RateLimiter} has single, common window for all entries. For which keys are computed with bit shifting ops. This is not + * precise, but has very low performance impact. + */ +public class BitShiftSingleWindowRateLimiter implements RateLimiter { + // Single window to make it less resource hungry and simpler for first iteration. + private int windowDuration; // duration in seconds. + private int requestLimit; + private volatile XnioExecutor.Key evictionKey; + // numbers of bits to shift. This will be used to determine prefix for 'requestCounter'; Its based on duration next power of + // 2; + private int bitsToShift; + // This map will store entries under key == prefix-IPAddress. where prefix is + // "major" part of timestamp + // Prefix math: + // 10000 --> 10 + // 10001 --> 10 + // Next: 10+1 --> 11000 + // In other words as long as bitsToShift wont cover ++, prefix will remain constant and allow fast and predictable + // calculation of key. + // Any key that does not start with prefix is outdated(window slid over it). + // NOTE: this can be improved with Long as key and having upper store ~tstamp and lower having pure byte[] representation + // of IP. Rest of logic would remain the same as for String key. + // TODO: sanity check, this is IO thread, so no need for cuncurrent map and AtomicInteger ? + private ConcurrentHashMap requestCounter = new ConcurrentHashMap(5000); + private static final String PREFIX_SEPARATOR = "-"; + private static final int TICK_BORDER = Integer.MAX_VALUE - 1; + /** + * Create bit shift limiter. Implementation will adjust values of bot windowDuration and requestLimit, to adjust to next ^2. + * Meaning duration of 33, will become 64. requestLimit will be adjusted to reflect this "stretch". + * + * @param windowDuration + * @param requestLimit + */ + public BitShiftSingleWindowRateLimiter(final int windowDuration, final int requestLimit) { + assert windowDuration > 0; + assert requestLimit > 0; + this.bitsToShift = determineBitShiftForDuration(windowDuration); + this.windowDuration = Math.toIntExact(1L << this.bitsToShift) / 1000; + // need to adjust requests, based on difference between bitshift duration and one that was passed here. + // This is done to cover cases when nextP2 is not close to duration, for instance original duration 33s, will + // switch to ~64, to have it work properly, we need to adjust limit as well. + this.requestLimit = (int)(((float)this.windowDuration/windowDuration) * requestLimit); + } + + @Override + public int getWindowDuration() { + return this.windowDuration; + } + + @Override + public int getRequestLimit() { + return this.requestLimit; + } + + @Override + public int timeToWindowSlide(final HttpServerExchange exchange) { + // window is common so we ignore parameter. + // nextPrefix->miliseconds-currentMilis/1000->s; + final long currentMilis = System.currentTimeMillis(); + return Math.toIntExact((((generatePrefix(currentMilis) + 1)< new AtomicInteger()); + return ai.accumulateAndGet(1, (value, upTick) -> { + // JIC + if (value < TICK_BORDER) { + return value + upTick; //uptick = +1 + } else { + return Integer.MAX_VALUE; + } + }); + } + + @Override + public int current(HttpServerExchange exchange) { + final String ipAddress = getIPAddress(exchange, true); + final String key = generateKey(ipAddress); + AtomicInteger entry = requestCounter.get(key); + if(entry != null) { + return entry.get(); + } else { + return -1; + } + } + + private String getIPAddress(final HttpServerExchange exchange, final boolean warn) { + final InetSocketAddress sourceAddress = exchange.getSourceAddress(); + InetAddress address = sourceAddress.getAddress(); + if (address == null) { + // this can happen when we have an unresolved X-forwarded-for address + // in this case we just return the IP of the balancer + //TODO: this needs impr + address = ((InetSocketAddress) exchange.getConnection().getPeerAddress()).getAddress(); + if(warn) { + UndertowLogger.REQUEST_LOGGER.rateLimitFailedToGetProperAddress(exchange.getRequestURI(), address.getHostAddress()); + } + } + return address.getHostAddress(); + } + + @Override + public String getLimiterID() { + return "bit-shift-window"; + } + + @Override + public RateLimitUnit getUnit() { + return RateLimitUnit.REQUEST; + } + + private void evictionCheck(HttpServerExchange exchange) { + // we need to parasite on IO threads for eviction. + XnioExecutor.Key key = this.evictionKey; + if (key == null) { + this.evictionKey = WorkerUtils.executeAfter(exchange.getIoThread(), new Runnable() { + @Override + public void run() { + evictionKey = null; + evictOldWindow(); + } + }, this.windowDuration, TimeUnit.SECONDS); + } + } + + private void evictOldWindow() { + // evict entries that are not 'current' or 'current+1' - just in case eviction starts in one window and finish in next. + // in such case 'current' will become stale already and will be evicted on next call. Thats fine. + // prefix + 1 will translate into bitshift ++ + final long currentPrefix = generatePrefix(); + final String current = String.valueOf(currentPrefix); + final String next = String.valueOf(currentPrefix + 1); + + ConcurrentHashMap.KeySetView keys = requestCounter.keySet(); + // remove obsolete keys + keys.removeIf(k -> !k.startsWith(current) && !k.startsWith(next)); + + } + + private String generateKey(String ipAddress) { + return generatePrefix() + PREFIX_SEPARATOR + ipAddress; + } + + private long generatePrefix(long timeMilis) { + return timeMilis >> this.bitsToShift; + } + + private long generatePrefix() { + return generatePrefix(System.currentTimeMillis()); + } + + private int nextPowerOf2(final int v) { + // this will return closest one bit, for 19, it will be 16. << 1 to get next highest + final int higherOneBitValue = Integer.highestOneBit(v); + if (v == higherOneBitValue) { + return higherOneBitValue; + } else { + return higherOneBitValue << 1; + } + } + + private int determineBitShiftForDuration(final int duration) { + // duration to milliseconds. + final int nextP2 = nextPowerOf2(duration * 1000); + // since its pure next power of 2, it has leading 1 and trailing zeros, which are equal to bit shift + return Integer.numberOfTrailingZeros(nextP2); + } + +} diff --git a/core/src/main/java/io/undertow/server/handlers/ratelimit/RateLimitUnit.java b/core/src/main/java/io/undertow/server/handlers/ratelimit/RateLimitUnit.java new file mode 100644 index 0000000000..966e4a4857 --- /dev/null +++ b/core/src/main/java/io/undertow/server/handlers/ratelimit/RateLimitUnit.java @@ -0,0 +1,34 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2025 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * 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 + * + * 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 io.undertow.server.handlers.ratelimit; + +public enum RateLimitUnit { + //see: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ -> 10.3. RateLimit quota unit registry + REQUEST("request"), CONTENT_BYTES("content-bytes"), CONCURRENT_REQUESTS("concurrent-requests"); + + private final String label; + + RateLimitUnit(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } +} diff --git a/core/src/main/java/io/undertow/server/handlers/ratelimit/RateLimiter.java b/core/src/main/java/io/undertow/server/handlers/ratelimit/RateLimiter.java new file mode 100644 index 0000000000..a64f5e41d5 --- /dev/null +++ b/core/src/main/java/io/undertow/server/handlers/ratelimit/RateLimiter.java @@ -0,0 +1,97 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2025 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * 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 + * + * 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 io.undertow.server.handlers.ratelimit; + +import io.undertow.server.HttpServerExchange; + + +public interface RateLimiter { + + /** + * Return time window duration(seconds). After this time elapses, entries are reset. + * + * @return + */ + int getWindowDuration(); + + /** + * Return time in seconds to window slide. Depending on implementation, parameter may be relevant( if time window is + * personalized for entry). + * + * @param ipAddress + * @return + */ + int timeToWindowSlide(HttpServerExchange exchange); + + /** + * Return + * + * @return + */ + int getRequestLimit(); + + /** + * Increment entries for exchange + * + * @param ipAddress + * @return + */ + int increment(HttpServerExchange exchange); + + /** + * + * @param exchange + * @return Positive value capped at limit or -1 if there is no entry. Positive value correspond to currently acumulated + * hits. + */ + int current(HttpServerExchange exchange); + + /** + * ID, this identifies limiter type in HTTP header + * + * @return + */ + String getLimiterID(); + + /** + * Return limit type. + * @return + */ + RateLimitUnit getUnit(); + + default String getPolicy() { + // Format: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ -> 3. RateLimit-Policy Field + return "\"" + getLimiterID() + "\";q=" + getRequestLimit() + ";qu=" + getUnit() + ";w=" + getWindowDuration(); + } + + default String getRemainingQuota(HttpServerExchange exchange) { + // Format: https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ -> 4. RateLimit Field + final int current = current(exchange); + int r; + if(current == -1) { + r = getRequestLimit(); + } else if(current >= getRequestLimit()) { + r= 0; + } else { + r = getRequestLimit() - current; + } + final int t = timeToWindowSlide(exchange); + return "\"" + getLimiterID() + "\";r=" + r + ";t=" + t; + } + +} diff --git a/core/src/main/java/io/undertow/server/handlers/ratelimit/RateLimitingHandler.java b/core/src/main/java/io/undertow/server/handlers/ratelimit/RateLimitingHandler.java new file mode 100644 index 0000000000..6355d4732b --- /dev/null +++ b/core/src/main/java/io/undertow/server/handlers/ratelimit/RateLimitingHandler.java @@ -0,0 +1,162 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2025 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * 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 + * + * 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 io.undertow.server.handlers.ratelimit; + +import java.net.InetAddress; +import java.net.InetSocketAddress; + +import io.undertow.UndertowLogger; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.util.HeaderMap; +import io.undertow.util.HttpString; + +/** + * Rate limiter per IP. Depending on implementation of limiting mechanism it can differ slightly on how it works, but baseline + * will be the same. Once limit is hit, this handler will reject incomming traffic with specified code and reply. Once engine + * determines that its fine to pass, it will invoke next handler in chain. + */ +public class RateLimitingHandler implements HttpHandler { + // defaults + /** + * Default status code to return if requests per duration is exceeded. + */ + public static final int DEFAULT_STATUS_CODE = 429; + + /** + * Default status message to return if requests per duration is exceeded. + */ + public static final String DEFAULT_STATUS_MESSAGE = "Too many requests"; + + /** + * Default status message to return if requests per duration is exceeded. + */ + public static final Boolean DEFAULT_ENFORCED = Boolean.TRUE; + + /** + * Default value, indicating that handler wont send back headers to indicate state of policy for particular entry + */ + public static final boolean DEFAULT_SIGNAL_LIMITS = false; + + /** + * Name of the rate limit policy header field defined in + * RateLimit header fields for HTTP + * (draft) # 3. RateLimit-Policy Field. + */ + public static final String HEADER_NAME_RATE_LIMIT_POLICY = "RateLimit-Policy"; + + /** + * Name of the rate limit remaining quota header field defined in + * RateLimit header fields for HTTP + * (draft) # 4. RateLimit Field. + */ + public static final String HEADER_NAME_RATE_LIMIT = "RateLimit"; + + private final HttpHandler nextHandler; + private RateLimiter rateLimiter; + private String statusMessage = DEFAULT_STATUS_MESSAGE; + private int statucCode = DEFAULT_STATUS_CODE; + private boolean enforced = DEFAULT_ENFORCED; + private boolean signalLimits = DEFAULT_SIGNAL_LIMITS; + + public RateLimitingHandler(final HttpHandler nextHandler, final RateLimiter rateLimiter) { + super(); + assert nextHandler != null; + assert rateLimiter != null; + this.nextHandler = nextHandler; + this.rateLimiter = rateLimiter; + } + + public RateLimitingHandler(final HttpHandler nextHandler, final RateLimiter rateLimiter, final String statusMessage, + final int code, final boolean enforced, final boolean signal) { + this(nextHandler, rateLimiter); + this.statucCode = code; + this.statusMessage = statusMessage; + this.enforced = enforced; + this.signalLimits = signal; + } + + @Override + public void handleRequest(final HttpServerExchange exchange) throws Exception { + // TODO: do we need some ID in logs in case there is more rate limiters set up based on something? + // TODO: add proxy unwinding here? getSourceAddress might take care of this + final InetSocketAddress sourceAddress = exchange.getSourceAddress(); + InetAddress address = sourceAddress.getAddress(); + if (address == null) { + // this can happen when we have an unresolved X-forwarded-for address + // in this case we just return the IP of the balancer + address = ((InetSocketAddress) exchange.getConnection().getPeerAddress()).getAddress(); + } + final String ipAddress = address.getHostAddress(); + final int currentRequestCount = rateLimiter.increment(exchange); + + //this has to be done before handling limit, in case it went over + if (this.signalLimits) { + final HeaderMap responseHeaders = exchange.getResponseHeaders(); + responseHeaders.add(HttpString.tryFromString(HEADER_NAME_RATE_LIMIT_POLICY), rateLimiter.getPolicy()); + if (this.enforced) { + responseHeaders.add(HttpString.tryFromString(HEADER_NAME_RATE_LIMIT), rateLimiter.getRemainingQuota(exchange)); + } + } + + if (currentRequestCount > rateLimiter.getRequestLimit()) { + UndertowLogger.REQUEST_LOGGER.exchangeExceedsRequestRateLimit(exchange.getRequestURI(), ipAddress, + rateLimiter.getRequestLimit(), rateLimiter.getWindowDuration(), rateLimiter.timeToWindowSlide(exchange), this.enforced); + if (this.enforced) { + exchange.setStatusCode(this.statucCode); + exchange.setReasonPhrase(this.statusMessage); + exchange.endExchange(); + return; + } + } + this.nextHandler.handleRequest(exchange); + } + + public String getStatusMessage() { + return statusMessage; + } + + public void setStatusMessage(String statusMessage) { + this.statusMessage = statusMessage; + } + + public int getStatucCode() { + return statucCode; + } + + public void setStatucCode(int statucCode) { + this.statucCode = statucCode; + } + + public boolean isEnforced() { + return enforced; + } + + public void setEnforced(boolean enforced) { + this.enforced = enforced; + } + + public boolean isSignalLimits() { + return signalLimits; + } + + public void setSignalLimits(boolean signalLimits) { + this.signalLimits = signalLimits; + } + +} diff --git a/core/src/test/java/io/undertow/server/handlers/RateLimiterTestCase.java b/core/src/test/java/io/undertow/server/handlers/RateLimiterTestCase.java new file mode 100644 index 0000000000..8a95e1bc64 --- /dev/null +++ b/core/src/test/java/io/undertow/server/handlers/RateLimiterTestCase.java @@ -0,0 +1,133 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2025 Red Hat, Inc., and individual contributors + * as indicated by the @author tags. + * + * 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 + * + * 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 io.undertow.server.handlers; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +import org.apache.http.Header; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import io.undertow.Handlers; +import io.undertow.server.HttpHandler; +import io.undertow.server.HttpServerExchange; +import io.undertow.server.handlers.ratelimit.BitShiftSingleWindowRateLimiter; +import io.undertow.server.handlers.ratelimit.RateLimiter; +import io.undertow.server.handlers.ratelimit.RateLimitingHandler; +import io.undertow.testutils.DefaultServer; +import io.undertow.testutils.HttpClientUtils; +import io.undertow.testutils.TestHttpClient; +import io.undertow.util.StatusCodes; + +@RunWith(DefaultServer.class) +public class RateLimiterTestCase { + //NOTE: even with limited testing this test takes long.... + private static final int DURATION = 60; + private static final int REQUEST_LIMIT = 20; + private RateLimiter limiter; + private RateLimitingHandler handler; + + @Before + public void setup() { + limiter = new BitShiftSingleWindowRateLimiter(DURATION, REQUEST_LIMIT); + handler = Handlers.rateLimitingHandler(limiter, new HttpHandler() { + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + exchange.setStatusCode(200); + exchange.endExchange(); + } + }); + handler.setSignalLimits(true); + DefaultServer.setRootHandler(new BlockingHandler(handler)); + } + + @Test + public void testWindowSliding() throws ExecutionException, InterruptedException { + TestHttpClient client = new TestHttpClient(); + try { + for (int i = 0; i < 3; i++) { + for (int requestCounter = 0; requestCounter <= limiter.getRequestLimit(); requestCounter++) { + HttpGet get = new HttpGet(DefaultServer.getDefaultServerURL()); + HttpResponse result = client.execute(get); + final int r = extract(RateLimitingHandler.HEADER_NAME_RATE_LIMIT, "r", result); + //dont check window as it will be random depending when test runs, only if its there + extract(RateLimitingHandler.HEADER_NAME_RATE_LIMIT, "t", result); + if (requestCounter == limiter.getRequestLimit() ) { + Assert.assertEquals("Iteration: " + i + ", request in sequence: " + requestCounter, handler.getStatucCode(), result.getStatusLine().getStatusCode()); + Assert.assertEquals("Iteration: " + i + ", request in sequence: " + requestCounter, handler.getStatusMessage(), result.getStatusLine().getReasonPhrase()); + Assert.assertEquals(limiter.getRequestLimit()-requestCounter, r); + } else { + Assert.assertEquals("Iteration: " + i + ", request in sequence: " + requestCounter, StatusCodes.OK, result.getStatusLine().getStatusCode()); + Assert.assertEquals(limiter.getRequestLimit()-requestCounter-1, r); + } + HttpClientUtils.readResponse(result); + } + // do 2x, since window slides "randomly" - from our POV + Thread.currentThread().sleep(limiter.getWindowDuration()*1000 * 2); + } + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + client.getConnectionManager().shutdown(); + } + } + + private int extract(final String headerName, final String key, final HttpResponse result) { + final Header[] headers = result.getHeaders(headerName); + Assert.assertNotNull(headers); + Assert.assertEquals(1, headers.length); + final Header h = headers[0]; + Assert.assertNotNull(h); + final String value = h.getValue(); + Assert.assertNotNull(value); + String[] splits = value.split(";"); + String kvp = null; + for(String split:splits) { + if(split.startsWith(key)) { + kvp=split; + break; + } + } + Assert.assertNotNull(kvp); + return Integer.parseInt(kvp.split("=")[1]); + } + + @Test + public void testDurationOfSlide() throws ExecutionException, InterruptedException { + int timeDiff = limiter.getWindowDuration()*1000/(limiter.getRequestLimit()-2); + TestHttpClient client = new TestHttpClient(); + try { + for (int requestCount = 0; requestCount * 2 < limiter.getRequestLimit(); requestCount++) { + HttpGet get = new HttpGet(DefaultServer.getDefaultServerURL()); + HttpResponse result = client.execute(get); + Assert.assertEquals(StatusCodes.OK, result.getStatusLine().getStatusCode()); + HttpClientUtils.readResponse(result); + Thread.currentThread().sleep(timeDiff); + } + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + client.getConnectionManager().shutdown(); + } + } +}