-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/rate limiting filter #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gvaguirres
wants to merge
22
commits into
main
Choose a base branch
from
feature/rate-limiting-filter
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
261a32a
Add rate limiting filter implementation
gvaguirres 2b33865
Add rate limiting filter test
gvaguirres e62d217
Merge remote-tracking branch 'origin/main' into feature/rate-limiting…
gvaguirres 650028e
Add error 429 too many requests in builder
gvaguirres 23c0667
Change to new reason phrases from builder in test class
gvaguirres a736812
Add a little description of the bucket and change "429" to sc_too_man…
gvaguirres 2077377
Add a description of how the token bucket algorithm works
gvaguirres c46f6e6
Update 429 error response with HTML body
gvaguirres b54c2aa
Merge remote-tracking branch 'origin/main' into feature/rate-limiting…
gvaguirres 4140c95
Add Rate Limiting Filter in connection handler
gvaguirres ff5e13e
Add guard against mission/invalid client ip
gvaguirres 6583833
Add method for prevent unbounded growth of buckets per‑IP
gvaguirres 2d93fc5
Add three test to check the removing of the buckets
gvaguirres e18b048
Refactorization of the method startCleanupThread, add method getBucke…
gvaguirres c38e84b
Add lifecycle control to cleanup
gvaguirres bf38050
Implement X-Forwarded-For in rate limiting filter
gvaguirres c504044
Merge remote-tracking branch 'origin/main' into feature/rate-limiting…
gvaguirres 9575288
Correction in x forwarded for logic
gvaguirres a610bac
Refactoring logic of x forwarded for to a method
gvaguirres d95116b
Fix logic in method resolve client ip
gvaguirres 0583b14
Add rate limiting filter IP test
gvaguirres 6fed940
Fix with code rabbit review
gvaguirres File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
162 changes: 162 additions & 0 deletions
162
src/main/java/org/example/filter/RateLimitingFilter.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| 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; | ||
| import java.util.concurrent.atomic.AtomicBoolean; | ||
|
|
||
| /** | ||
| * 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 | ||
| */ | ||
|
|
||
| public class RateLimitingFilter implements Filter { | ||
| private static final Logger logger = Logger.getLogger(RateLimitingFilter.class.getName()); | ||
| private static final Map<String, BucketWrapper> buckets = new ConcurrentHashMap<>(); | ||
| private static final long CAPACITY = 10; | ||
| 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); | ||
| if (cleanupStarted.compareAndSet(false, true)) { | ||
| cleanupThread = startCleanupThread(); | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Intercepts the request and checks if the client has enough tokens. | ||
| */ | ||
| @Override | ||
| public void doFilter(HttpRequest request, HttpResponseBuilder response, FilterChain chain) { | ||
|
|
||
| String clientIp = resolveClientIp(request, response); | ||
|
|
||
| if (clientIp == null) return; | ||
|
|
||
| BucketWrapper wrapper = buckets.computeIfAbsent(clientIp, k -> new BucketWrapper(createNewBucket())); | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| wrapper.updateAccess(); | ||
|
|
||
| if (wrapper.bucket.tryConsume(1)) { | ||
| chain.doFilter(request, response); | ||
| } else { | ||
| logger.warning("Limit exceeded per IP: " + clientIp); | ||
| response.setStatusCode(HttpResponseBuilder.SC_TOO_MANY_REQUESTS); | ||
| response.setBody("<h1>429 Too Many Requests</h1><p> Limit of requests exceeded.</p>\n"); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void destroy() { | ||
| Thread t = cleanupThread; | ||
| if (t != null) { | ||
| t.interrupt(); | ||
| cleanupThread = null; | ||
| } | ||
| cleanupStarted.set(false); | ||
| buckets.clear(); | ||
| } | ||
|
|
||
| /** | ||
| * Configures a new Bucket with the specified bandwidth. | ||
| */ | ||
| private Bucket createNewBucket() { | ||
| return Bucket.builder() | ||
| .addLimit(Bandwidth.builder() | ||
| .capacity(CAPACITY) | ||
| .refillGreedy(REFILL_TOKENS, refillPeriod) | ||
| .build()) | ||
| .build(); | ||
| } | ||
|
|
||
| /** | ||
| * 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(); | ||
| } | ||
| } | ||
|
|
||
| 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("<h1>400 Bad Request</h1><p>Missing client IP.</p>\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()) { | ||
| try { | ||
| //it checks every 10 minutes | ||
| Thread.sleep(Duration.ofMinutes(10).toMillis()); | ||
|
|
||
| cleanupIdleBuckets(); | ||
|
|
||
| } catch (InterruptedException _) { | ||
| Thread.currentThread().interrupt(); | ||
| break; | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
|
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
48 changes: 48 additions & 0 deletions
48
src/test/java/org/example/filter/RateLimitingFilterIpTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| 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<String, String> 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(){ | ||
|
|
||
| Map<String, String> headers = new HashMap<>(); | ||
| when(request.getHeaders()).thenReturn(headers); | ||
| when(request.getAttribute("clientIp")).thenReturn("10.0.0.5"); | ||
|
|
||
| String finalIp = resolveClientIp(request, response); | ||
|
|
||
| assertEquals("10.0.0.5", finalIp); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
138 changes: 138 additions & 0 deletions
138
src/test/java/org/example/filter/RateLimitingFilterTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| 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.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.*; | ||
|
|
||
| @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.destroy(); | ||
| } | ||
|
|
||
| @Test | ||
| void shouldAllowRequest_WhenTokensAreAvailable(){ | ||
|
|
||
| filter.doFilter(request, response, filterChain); | ||
|
|
||
| verify(filterChain, times(1)).doFilter(request, response); | ||
| assertEquals(SC_OK, response.getStatusCode()); | ||
| } | ||
|
|
||
| @Test | ||
| void shouldNotAllowRequest_WhenTokensAreNotAvailable(){ | ||
|
|
||
| //capacity of the bucket is 10 | ||
| for(int i = 0; i < 11; i++ ) | ||
| filter.doFilter(request, response, filterChain); | ||
|
|
||
| assertEquals(SC_TOO_MANY_REQUESTS, 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(SC_TOO_MANY_REQUESTS, response.getStatusCode()); | ||
| //Second request should be 200 | ||
| 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()); | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.