From 261a32afd5a8d70289c1cfc1acb4958038b22bb6 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 12:35:41 +0100 Subject: [PATCH 01/19] Add rate limiting filter implementation --- .../example/filter/RateLimitingFilter.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/main/java/org/example/filter/RateLimitingFilter.java diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java new file mode 100644 index 00000000..dc4c7fdb --- /dev/null +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -0,0 +1,71 @@ +package org.example.filter; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * Filter responsible for limiting the number of requests per client IP. + * Implements the Token Bucket algorithm using the Bucket4j library. + */ + +public class RateLimitingFilter implements Filter { + private static final Logger logger = Logger.getLogger(RateLimitingFilter.class.getName()); + private static final Map buckets = new ConcurrentHashMap<>(); + private static final long capacity = 10; + private static final long refillTokens = 1; + private final Duration refillPeriod = Duration.ofSeconds(10); + + @Override + public void init() { + logger.info("RateLimitingFilter initialized with capacity: " + capacity); + } + + /** + * Intercepts the request and checks if the client has enough tokens. + */ + + @Override + public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { + + String clientIp = (String) request.getAttribute("clientIp"); + + Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createNewBucket()); + + if (bucket.tryConsume(1)) { + chain.doFilter(request, response); + } else { + logger.warning("Limit exceeded per IP: " + clientIp); + //todo: cambiar 429 a la nueva configuracion del builder + //agregar el rate limit exceeded cuando el ip es privado como vimos en la clase??? ip 172.19 + response.setStatusCode(429); + response.setBody("429 Too Many Requests: limit of requests exceeded.\n"); + } + } + + @Override + public void destroy() { + buckets.clear(); + } + + /** + * Configures a new Bucket with the specified bandwidth. + */ + private Bucket createNewBucket() { + return Bucket.builder() + .addLimit(Bandwidth.builder() + .capacity(capacity) + .refillGreedy(refillTokens, refillPeriod) + .build()) + .build(); + } + + public void clearBuckets(){ + buckets.clear(); + } +} From 2b3386584b3fd71027592ab2cd8e99e06dda93dc Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 12:36:11 +0100 Subject: [PATCH 02/19] Add rate limiting filter test --- .../filter/RateLimitingFilterTest.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/test/java/org/example/filter/RateLimitingFilterTest.java diff --git a/src/test/java/org/example/filter/RateLimitingFilterTest.java b/src/test/java/org/example/filter/RateLimitingFilterTest.java new file mode 100644 index 00000000..cdc63590 --- /dev/null +++ b/src/test/java/org/example/filter/RateLimitingFilterTest.java @@ -0,0 +1,77 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.http.HttpResponse; +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RateLimitingFilterTest { + + @Mock + FilterChain filterChain; + + private RateLimitingFilter filter; + private HttpRequest request; + private HttpResponseBuilder response; + + @BeforeEach + void setUp(){ + filter = new RateLimitingFilter(); + request = new HttpRequest("GET", "/", "HTTP/1.1", new HashMap<>(), ""); + request.setAttribute("clientIp", "127.0.0.1"); + response = new HttpResponseBuilder(); + filter.clearBuckets(); + } + + @Test + void shouldAllowRequestWhenTokensAreAvailable(){ + + filter.doFilter(request, response, filterChain); + + verify(filterChain, times(1)).doFilter(request, response); + assertEquals(200, response.getStatusCode()); + } + + @Test + void shouldNotAllowRequestWhenTokensAreNotAvailable(){ + + //capacity of the bucket is 10 + for(int i = 0; i < 11; i++ ) + filter.doFilter(request, response, filterChain); + + assertEquals(429, response.getStatusCode()); + verify(filterChain, times(10)).doFilter(any(), any()); + } + + @Test + void shouldHaveSeparateBucketsPerIp(){ + + //Request 1 + for(int i = 0; i < 11; i++) + filter.doFilter(request, response, filterChain); + + //Request 2 with a different Ip + HttpRequest request2 = new HttpRequest("GET", "/", "HTTP/1.1", new HashMap<>(), ""); + request2.setAttribute("clientIp", "127.2.2.2"); + HttpResponseBuilder response2 = new HttpResponseBuilder(); + + filter.doFilter(request2, response2, filterChain); + + //First request should be 429 because it exceeded the capacity of the bucket (10) + assertEquals(429, response.getStatusCode()); + //Second request should be 200 + assertEquals(200, response2.getStatusCode()); + } + + +} From 650028efa60f3b9609364aa93048d9c95ba27794 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 12:44:11 +0100 Subject: [PATCH 03/19] Add error 429 too many requests in builder --- src/main/java/org/example/http/HttpResponseBuilder.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/example/http/HttpResponseBuilder.java b/src/main/java/org/example/http/HttpResponseBuilder.java index e84579e9..051ae48b 100644 --- a/src/main/java/org/example/http/HttpResponseBuilder.java +++ b/src/main/java/org/example/http/HttpResponseBuilder.java @@ -24,6 +24,7 @@ public class HttpResponseBuilder { public static final int SC_UNAUTHORIZED = 401; public static final int SC_FORBIDDEN = 403; public static final int SC_NOT_FOUND = 404; + public static final int SC_TOO_MANY_REQUESTS = 429; // SERVER ERROR public static final int SC_INTERNAL_SERVER_ERROR = 500; @@ -55,6 +56,7 @@ public class HttpResponseBuilder { Map.entry(SC_UNAUTHORIZED, "Unauthorized"), Map.entry(SC_FORBIDDEN, "Forbidden"), Map.entry(SC_NOT_FOUND, "Not Found"), + Map.entry(SC_TOO_MANY_REQUESTS, "Too Many Requests"), Map.entry(SC_INTERNAL_SERVER_ERROR, "Internal Server Error"), Map.entry(SC_BAD_GATEWAY, "Bad Gateway"), Map.entry(SC_SERVICE_UNAVAILABLE, "Service Unavailable"), From 23c0667e926b41887c6e9de766376a6f84853029 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 12:54:36 +0100 Subject: [PATCH 04/19] Change to new reason phrases from builder in test class --- .../org/example/filter/RateLimitingFilterTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/example/filter/RateLimitingFilterTest.java b/src/test/java/org/example/filter/RateLimitingFilterTest.java index cdc63590..24cf28b7 100644 --- a/src/test/java/org/example/filter/RateLimitingFilterTest.java +++ b/src/test/java/org/example/filter/RateLimitingFilterTest.java @@ -11,6 +11,8 @@ import java.net.http.HttpResponse; import java.util.HashMap; +import static org.example.http.HttpResponseBuilder.SC_OK; +import static org.example.http.HttpResponseBuilder.SC_TOO_MANY_REQUESTS; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.*; @@ -39,7 +41,7 @@ void shouldAllowRequestWhenTokensAreAvailable(){ filter.doFilter(request, response, filterChain); verify(filterChain, times(1)).doFilter(request, response); - assertEquals(200, response.getStatusCode()); + assertEquals(SC_OK, response.getStatusCode()); } @Test @@ -49,7 +51,7 @@ void shouldNotAllowRequestWhenTokensAreNotAvailable(){ for(int i = 0; i < 11; i++ ) filter.doFilter(request, response, filterChain); - assertEquals(429, response.getStatusCode()); + assertEquals(SC_TOO_MANY_REQUESTS, response.getStatusCode()); verify(filterChain, times(10)).doFilter(any(), any()); } @@ -68,9 +70,9 @@ void shouldHaveSeparateBucketsPerIp(){ filter.doFilter(request2, response2, filterChain); //First request should be 429 because it exceeded the capacity of the bucket (10) - assertEquals(429, response.getStatusCode()); + assertEquals(SC_TOO_MANY_REQUESTS, response.getStatusCode()); //Second request should be 200 - assertEquals(200, response2.getStatusCode()); + assertEquals(SC_OK, response2.getStatusCode()); } From a7368123496e485c302c92c1796576406008cc98 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 13:31:35 +0100 Subject: [PATCH 05/19] Add a little description of the bucket and change "429" to sc_too_many_requests --- src/main/java/org/example/filter/RateLimitingFilter.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index dc4c7fdb..473aab66 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -12,6 +12,7 @@ /** * Filter responsible for limiting the number of requests per client IP. * Implements the Token Bucket algorithm using the Bucket4j library. + * The capacity of the bucket is 10, and it refills one token per 10 seconds */ public class RateLimitingFilter implements Filter { @@ -41,9 +42,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh chain.doFilter(request, response); } else { logger.warning("Limit exceeded per IP: " + clientIp); - //todo: cambiar 429 a la nueva configuracion del builder - //agregar el rate limit exceeded cuando el ip es privado como vimos en la clase??? ip 172.19 - response.setStatusCode(429); + response.setStatusCode(HttpResponseBuilder.SC_TOO_MANY_REQUESTS); response.setBody("429 Too Many Requests: limit of requests exceeded.\n"); } } From 20773773454524d8d83fc6779545fc62764da25c Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 13:46:45 +0100 Subject: [PATCH 06/19] Add a description of how the token bucket algorithm works --- src/main/java/org/example/filter/RateLimitingFilter.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 473aab66..a00decc3 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -10,8 +10,15 @@ import java.util.logging.Logger; /** - * Filter responsible for limiting the number of requests per client IP. + * Rate Limiting Filter responsible for limiting the number of requests per client IP. * Implements the Token Bucket algorithm using the Bucket4j library. + * How it works: + * A "bucket" hold a fixed number of tokens (capacity) + * Each incoming request attempts to consume exactly one token + * If a token is available, the request is processed and the token is removed + * If the bucket is empty, the request is rejected with an HTTP 429 (Too Many Requests) status + * Tokens are replenished at a fixed rate over time (Refill Rate), up to the maximum capaci + * This allows for occasional bursts of traffic while maintaining a steady long-term rate limit * The capacity of the bucket is 10, and it refills one token per 10 seconds */ From c46f6e6877f32ffe50773e57b068a7fc6c9b53e1 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 15:03:56 +0100 Subject: [PATCH 07/19] Update 429 error response with HTML body --- src/main/java/org/example/filter/RateLimitingFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index a00decc3..839ff9fb 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -50,7 +50,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh } else { logger.warning("Limit exceeded per IP: " + clientIp); response.setStatusCode(HttpResponseBuilder.SC_TOO_MANY_REQUESTS); - response.setBody("429 Too Many Requests: limit of requests exceeded.\n"); + response.setBody("

429 Too Many Requests

Limit of requests exceeded.

\n"); } } From 4140c9589c23aa222c9fb05633c49e222a1f5f04 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Tue, 24 Feb 2026 15:10:43 +0100 Subject: [PATCH 08/19] Add Rate Limiting Filter in connection handler --- src/main/java/org/example/ConnectionHandler.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/ConnectionHandler.java b/src/main/java/org/example/ConnectionHandler.java index 9fc219d4..a9685cdc 100644 --- a/src/main/java/org/example/ConnectionHandler.java +++ b/src/main/java/org/example/ConnectionHandler.java @@ -26,6 +26,9 @@ public ConnectionHandler(Socket client) { } private List buildFilters() { List list = new ArrayList<>(); + + list.add(new org.example.filter.RateLimitingFilter()); + AppConfig config = ConfigLoader.get(); AppConfig.IpFilterConfig ipFilterConfig = config.ipFilter(); if (Boolean.TRUE.equals(ipFilterConfig.enabled())) { @@ -57,7 +60,8 @@ public void runConnectionHandler() throws IOException { int statusCode = response.getStatusCode(); if (statusCode == HttpResponseBuilder.SC_FORBIDDEN || - statusCode == HttpResponseBuilder.SC_BAD_REQUEST) { + statusCode == HttpResponseBuilder.SC_BAD_REQUEST || + statusCode == HttpResponseBuilder.SC_TOO_MANY_REQUESTS) { byte[] responseBytes = response.build(); client.getOutputStream().write(responseBytes); client.getOutputStream().flush(); From ff5e13ecdd8a75b0d7482374bae810ba14c376c6 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 10:38:29 +0100 Subject: [PATCH 09/19] Add guard against mission/invalid client ip --- src/main/java/org/example/filter/RateLimitingFilter.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 839ff9fb..75a87945 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -41,8 +41,14 @@ public void init() { @Override public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { - String clientIp = (String) request.getAttribute("clientIp"); + Object clientIpAttr = request.getAttribute("clientIp"); + if (!(clientIpAttr instanceof String) || ((String) clientIpAttr).isBlank()) { + response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); + response.setBody("

400 Bad Request

Missing client IP.

\n"); + return; + } + String clientIp = (String) clientIpAttr; Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createNewBucket()); if (bucket.tryConsume(1)) { From 65838332a4b54896801bab0a1f1339e8ea7540c9 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 14:40:33 +0100 Subject: [PATCH 10/19] =?UTF-8?q?Add=20method=20for=20prevent=20unbounded?= =?UTF-8?q?=20growth=20of=20buckets=20per=E2=80=91IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/filter/RateLimitingFilter.java | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 75a87945..27a6cb48 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -24,20 +24,21 @@ public class RateLimitingFilter implements Filter { private static final Logger logger = Logger.getLogger(RateLimitingFilter.class.getName()); - private static final Map buckets = new ConcurrentHashMap<>(); - private static final long capacity = 10; - private static final long refillTokens = 1; - private final Duration refillPeriod = Duration.ofSeconds(10); + private static final Map buckets = new ConcurrentHashMap<>(); + private static final long CAPACITY = 10; + private static final long REFILL_TOKENS = 1; + private final Duration REFILL_PERIOD = Duration.ofSeconds(10); + private static final int MAX_BUCKETS_THRESHOLD = 1000; @Override public void init() { - logger.info("RateLimitingFilter initialized with capacity: " + capacity); + logger.info("RateLimitingFilter initialized with capacity: " + CAPACITY); + startCleanupThread(); } /** * Intercepts the request and checks if the client has enough tokens. */ - @Override public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { @@ -49,9 +50,12 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh } String clientIp = (String) clientIpAttr; - Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createNewBucket()); - if (bucket.tryConsume(1)) { + BucketWrapper wrapper = buckets.computeIfAbsent(clientIp, k -> new BucketWrapper(createNewBucket())); + + wrapper.updateAccess(); + + if (wrapper.bucket.tryConsume(1)) { chain.doFilter(request, response); } else { logger.warning("Limit exceeded per IP: " + clientIp); @@ -71,8 +75,8 @@ public void destroy() { private Bucket createNewBucket() { return Bucket.builder() .addLimit(Bandwidth.builder() - .capacity(capacity) - .refillGreedy(refillTokens, refillPeriod) + .capacity(CAPACITY) + .refillGreedy(REFILL_TOKENS, REFILL_PERIOD) .build()) .build(); } @@ -80,4 +84,45 @@ private Bucket createNewBucket() { public void clearBuckets(){ buckets.clear(); } + + /** + * Track the last access time of every bucket + */ + private static class BucketWrapper { + private final Bucket bucket; + private volatile long lastAccessTime; + + BucketWrapper(Bucket bucket) { + this.bucket = bucket; + this.lastAccessTime = System.currentTimeMillis(); + } + + void updateAccess() { + this.lastAccessTime = System.currentTimeMillis(); + } + } + + private void startCleanupThread() { + Thread cleanupThread = new Thread(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + //it checks every 10 minutes + Thread.sleep(Duration.ofMinutes(10).toMillis()); + + //it will only clean when the size of the buckets is more than 1000 + if (buckets.size() > MAX_BUCKETS_THRESHOLD) { + long idleThreshold = System.currentTimeMillis() - Duration.ofMinutes(30).toMillis(); + buckets.entrySet().removeIf(entry -> entry.getValue().lastAccessTime < idleThreshold); + } + + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + cleanupThread.setDaemon(true); + cleanupThread.start(); + } + } From 2d93fc51f0e4f62aba3d5102b8bb4313c43ba53a Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 21:25:33 +0100 Subject: [PATCH 11/19] Add three test to check the removing of the buckets --- .../filter/RateLimitingFilterTest.java | 69 +++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/test/java/org/example/filter/RateLimitingFilterTest.java b/src/test/java/org/example/filter/RateLimitingFilterTest.java index 24cf28b7..d115d9c4 100644 --- a/src/test/java/org/example/filter/RateLimitingFilterTest.java +++ b/src/test/java/org/example/filter/RateLimitingFilterTest.java @@ -7,8 +7,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; - -import java.net.http.HttpResponse; import java.util.HashMap; import static org.example.http.HttpResponseBuilder.SC_OK; @@ -32,11 +30,11 @@ void setUp(){ request = new HttpRequest("GET", "/", "HTTP/1.1", new HashMap<>(), ""); request.setAttribute("clientIp", "127.0.0.1"); response = new HttpResponseBuilder(); - filter.clearBuckets(); + filter.destroy(); } @Test - void shouldAllowRequestWhenTokensAreAvailable(){ + void shouldAllowRequest_WhenTokensAreAvailable(){ filter.doFilter(request, response, filterChain); @@ -45,7 +43,7 @@ void shouldAllowRequestWhenTokensAreAvailable(){ } @Test - void shouldNotAllowRequestWhenTokensAreNotAvailable(){ + void shouldNotAllowRequest_WhenTokensAreNotAvailable(){ //capacity of the bucket is 10 for(int i = 0; i < 11; i++ ) @@ -75,5 +73,66 @@ void shouldHaveSeparateBucketsPerIp(){ assertEquals(SC_OK, response2.getStatusCode()); } + @Test + void shouldDeleteOldBuckets_WhenSizeIsMoreThanThreshold(){ + + filter.init(); + + for(int i = 0; i < 1001; i++ ){ + String fakeIp = "192.168.1." + i; + request.setAttribute("clientIp", fakeIp); + filter.doFilter(request, response, filterChain); + } + + assertEquals(1001, filter.getBucketsCount()); + + filter.ageBucketsForTesting(3600000); + filter.cleanupIdleBuckets(); + + assertEquals(0, filter.getBucketsCount()); + } + + @Test + void shouldNotDeleteOldBuckets_WhenSizeIsLessThanThreshold(){ + filter.init(); + + for(int i = 0; i < 1000; i++ ){ + String fakeIp = "192.168.1." + i; + request.setAttribute("clientIp", fakeIp); + filter.doFilter(request, response, filterChain); + } + + assertEquals(1000, filter.getBucketsCount()); + + filter.ageBucketsForTesting(3600000); + filter.cleanupIdleBuckets(); + + assertEquals(1000, filter.getBucketsCount()); + + } + + @Test + void shouldDeleteOnlyExpiredBuckets_WhenAreOld(){ + filter.init(); + + for(int i = 0; i < 1001; i++ ){ + String fakeIp = "192.168.1." + i; + request.setAttribute("clientIp", fakeIp); + filter.doFilter(request, response, filterChain); + } + + assertEquals(1001, filter.getBucketsCount()); + filter.ageBucketsForTesting(3600000); + + for(int i = 0; i < 500; i++ ){ + String fakeIp = "192.168.1." + i; + request.setAttribute("clientIp", fakeIp); + filter.doFilter(request, response, filterChain); + } + + filter.cleanupIdleBuckets(); + + assertEquals(500, filter.getBucketsCount()); + } } From e18b048b9d85d5523b079c4983f34e66313791b2 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 21:47:56 +0100 Subject: [PATCH 12/19] Refactorization of the method startCleanupThread, add method getBucketsCount and ageBucketsForTesting --- .../example/filter/RateLimitingFilter.java | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 27a6cb48..b9189747 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -27,7 +27,7 @@ public class RateLimitingFilter implements Filter { private static final Map buckets = new ConcurrentHashMap<>(); private static final long CAPACITY = 10; private static final long REFILL_TOKENS = 1; - private final Duration REFILL_PERIOD = Duration.ofSeconds(10); + private final Duration refillPeriod = Duration.ofSeconds(10); private static final int MAX_BUCKETS_THRESHOLD = 1000; @Override @@ -76,15 +76,11 @@ private Bucket createNewBucket() { return Bucket.builder() .addLimit(Bandwidth.builder() .capacity(CAPACITY) - .refillGreedy(REFILL_TOKENS, REFILL_PERIOD) + .refillGreedy(REFILL_TOKENS, refillPeriod) .build()) .build(); } - public void clearBuckets(){ - buckets.clear(); - } - /** * Track the last access time of every bucket */ @@ -102,27 +98,40 @@ void updateAccess() { } } - private void startCleanupThread() { - Thread cleanupThread = new Thread(() -> { + public void startCleanupThread() { + Thread.ofVirtual().start(() -> { while (!Thread.currentThread().isInterrupted()) { try { //it checks every 10 minutes Thread.sleep(Duration.ofMinutes(10).toMillis()); - //it will only clean when the size of the buckets is more than 1000 - if (buckets.size() > MAX_BUCKETS_THRESHOLD) { - long idleThreshold = System.currentTimeMillis() - Duration.ofMinutes(30).toMillis(); - buckets.entrySet().removeIf(entry -> entry.getValue().lastAccessTime < idleThreshold); - } - + cleanupIdleBuckets(); - } catch (InterruptedException e) { + } catch (InterruptedException _) { Thread.currentThread().interrupt(); + break; } } }); - cleanupThread.setDaemon(true); - cleanupThread.start(); + } + + public void cleanupIdleBuckets() { + //it will only clean when the size of the buckets is more than 1000 + if (buckets.size() > MAX_BUCKETS_THRESHOLD) { + long idleThreshold = System.currentTimeMillis() - Duration.ofMinutes(30).toMillis(); + buckets.entrySet().removeIf(entry -> entry.getValue().lastAccessTime < idleThreshold); + } + } + + public int getBucketsCount() { + return buckets.size(); + } + + public void ageBucketsForTesting(long millisToSubtract) { + for (BucketWrapper wrapper : buckets.values()) { + long oldTime = wrapper.lastAccessTime; + wrapper.lastAccessTime = oldTime - millisToSubtract; + } } } From c38e84b217a22e25a37b109ace164d36829445d6 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Wed, 25 Feb 2026 21:59:56 +0100 Subject: [PATCH 13/19] Add lifecycle control to cleanup --- .../org/example/filter/RateLimitingFilter.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index b9189747..5ce846a3 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; +import java.util.concurrent.atomic.AtomicBoolean; /** * Rate Limiting Filter responsible for limiting the number of requests per client IP. @@ -29,11 +30,15 @@ public class RateLimitingFilter implements Filter { private static final long REFILL_TOKENS = 1; private final Duration refillPeriod = Duration.ofSeconds(10); private static final int MAX_BUCKETS_THRESHOLD = 1000; + private final AtomicBoolean cleanupStarted = new AtomicBoolean(false); + private volatile Thread cleanupThread; @Override public void init() { logger.info("RateLimitingFilter initialized with capacity: " + CAPACITY); - startCleanupThread(); + if (cleanupStarted.compareAndSet(false, true)) { + cleanupThread = startCleanupThread(); + } } /** @@ -66,6 +71,12 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh @Override public void destroy() { + Thread t = cleanupThread; + if (t != null) { + t.interrupt(); + cleanupThread = null; + } + cleanupStarted.set(false); buckets.clear(); } @@ -98,8 +109,8 @@ void updateAccess() { } } - public void startCleanupThread() { - Thread.ofVirtual().start(() -> { + public Thread startCleanupThread() { + return Thread.ofVirtual().name("rate-limit-cleanup").start(() -> { while (!Thread.currentThread().isInterrupted()) { try { //it checks every 10 minutes From bf3805073fcadff6b957cfc9aa1812c600e93f18 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 12:59:39 +0100 Subject: [PATCH 14/19] Implement X-Forwarded-For in rate limiting filter --- src/main/java/org/example/filter/RateLimitingFilter.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 5ce846a3..2e0b91c2 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -48,13 +48,18 @@ public void init() { public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { Object clientIpAttr = request.getAttribute("clientIp"); - if (!(clientIpAttr instanceof String) || ((String) clientIpAttr).isBlank()) { + + if (!(clientIpAttr instanceof String clientIp) || (clientIp.isBlank())) { response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); response.setBody("

400 Bad Request

Missing client IP.

\n"); return; } - String clientIp = (String) clientIpAttr; + String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); + + if( xForwardedFor != null || xForwardedFor.isBlank() ) { + clientIp = xForwardedFor.split(",")[0].trim(); + } BucketWrapper wrapper = buckets.computeIfAbsent(clientIp, k -> new BucketWrapper(createNewBucket())); From 95752884b02d36f4a77d5cf12dd3428c5b8c3b13 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 13:37:41 +0100 Subject: [PATCH 15/19] Correction in x forwarded for logic --- src/main/java/org/example/filter/RateLimitingFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 2e0b91c2..3994eb8c 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -57,7 +57,7 @@ public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterCh String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); - if( xForwardedFor != null || xForwardedFor.isBlank() ) { + if( xForwardedFor != null && xForwardedFor.isBlank() ) { clientIp = xForwardedFor.split(",")[0].trim(); } From a610bac092aeaa49224673128b730b95429e1ac2 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 15:38:16 +0100 Subject: [PATCH 16/19] Refactoring logic of x forwarded for to a method --- .../example/filter/RateLimitingFilter.java | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index 3994eb8c..e0ffdfcd 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -47,19 +47,9 @@ public void init() { @Override public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { - Object clientIpAttr = request.getAttribute("clientIp"); + String clientIp = resolveClientIp(request, response); - if (!(clientIpAttr instanceof String clientIp) || (clientIp.isBlank())) { - response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); - response.setBody("

400 Bad Request

Missing client IP.

\n"); - return; - } - - String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); - - if( xForwardedFor != null && xForwardedFor.isBlank() ) { - clientIp = xForwardedFor.split(",")[0].trim(); - } + if (clientIp == null) return; BucketWrapper wrapper = buckets.computeIfAbsent(clientIp, k -> new BucketWrapper(createNewBucket())); @@ -114,6 +104,24 @@ void updateAccess() { } } + public static String resolveClientIp(HttpRequest request, HttpResponseBuilder response) { + Object clientIpAttr = request.getAttribute("clientIp"); + + if (!(clientIpAttr instanceof String clientIp) || (clientIp.isBlank())) { + response.setStatusCode(HttpResponseBuilder.SC_BAD_REQUEST); + response.setBody("

400 Bad Request

Missing client IP.

\n"); + return null; + } + + String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); + + if( xForwardedFor != null && xForwardedFor.isBlank() ) { + clientIp = xForwardedFor.split(",")[0].trim(); + } + + return clientIp; + } + public Thread startCleanupThread() { return Thread.ofVirtual().name("rate-limit-cleanup").start(() -> { while (!Thread.currentThread().isInterrupted()) { From d95116b96c9ac34241a24868ab89dd1889ad636a Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 15:46:40 +0100 Subject: [PATCH 17/19] Fix logic in method resolve client ip --- src/main/java/org/example/filter/RateLimitingFilter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/filter/RateLimitingFilter.java b/src/main/java/org/example/filter/RateLimitingFilter.java index e0ffdfcd..e5a9667b 100644 --- a/src/main/java/org/example/filter/RateLimitingFilter.java +++ b/src/main/java/org/example/filter/RateLimitingFilter.java @@ -105,6 +105,7 @@ void updateAccess() { } public static String resolveClientIp(HttpRequest request, HttpResponseBuilder response) { + Object clientIpAttr = request.getAttribute("clientIp"); if (!(clientIpAttr instanceof String clientIp) || (clientIp.isBlank())) { @@ -115,7 +116,7 @@ public static String resolveClientIp(HttpRequest request, HttpResponseBuilder re String xForwardedFor = request.getHeaders().get("X-Forwarded-For"); - if( xForwardedFor != null && xForwardedFor.isBlank() ) { + if( xForwardedFor != null && !xForwardedFor.isBlank() ) { clientIp = xForwardedFor.split(",")[0].trim(); } From 0583b1455ca8ad43eb765ae53fb946b3a70878b4 Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 15:52:13 +0100 Subject: [PATCH 18/19] Add rate limiting filter IP test --- .../filter/RateLimitingFilterIpTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/test/java/org/example/filter/RateLimitingFilterIpTest.java diff --git a/src/test/java/org/example/filter/RateLimitingFilterIpTest.java b/src/test/java/org/example/filter/RateLimitingFilterIpTest.java new file mode 100644 index 00000000..c63dc3df --- /dev/null +++ b/src/test/java/org/example/filter/RateLimitingFilterIpTest.java @@ -0,0 +1,47 @@ +package org.example.filter; + +import org.example.http.HttpResponseBuilder; +import org.example.httpparser.HttpRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.Map; + +import static org.example.filter.RateLimitingFilter.resolveClientIp; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RateLimitingFilterIpTest { + + @Mock HttpRequest request; + @Mock HttpResponseBuilder response; + + @Test + void shouldUseXForwarded_WhenPresent(){ + + Map headers = new HashMap<>(); + headers.put("X-Forwarded-For", "203.0.113.195"); + + when(request.getHeaders()).thenReturn(headers); + when(request.getAttribute("clientIp")).thenReturn("127.0.0.1"); + + String finalIp = resolveClientIp(request, response); + + assertEquals("203.0.113.195", finalIp); + } + + @Test + void shouldFallbackToAttribute_WhenXForwardedForIsNotPresent(){ + + when(request.getAttribute("clientIp")).thenReturn(Map.of()); + when(request.getAttribute("clientIp")).thenReturn("10.0.0.5"); + + String finalIp = resolveClientIp(request, response); + + assertEquals("10.0.0.5", finalIp); + } +} From 6fed94073bc76a12e361e5717abe037866ce269f Mon Sep 17 00:00:00 2001 From: Gabriela Aguirre Date: Fri, 27 Feb 2026 16:03:01 +0100 Subject: [PATCH 19/19] Fix with code rabbit review --- src/test/java/org/example/filter/RateLimitingFilterIpTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/example/filter/RateLimitingFilterIpTest.java b/src/test/java/org/example/filter/RateLimitingFilterIpTest.java index c63dc3df..f27cd8ec 100644 --- a/src/test/java/org/example/filter/RateLimitingFilterIpTest.java +++ b/src/test/java/org/example/filter/RateLimitingFilterIpTest.java @@ -37,7 +37,8 @@ void shouldUseXForwarded_WhenPresent(){ @Test void shouldFallbackToAttribute_WhenXForwardedForIsNotPresent(){ - when(request.getAttribute("clientIp")).thenReturn(Map.of()); + Map headers = new HashMap<>(); + when(request.getHeaders()).thenReturn(headers); when(request.getAttribute("clientIp")).thenReturn("10.0.0.5"); String finalIp = resolveClientIp(request, response);