Skip to content

Commit 97381b0

Browse files
Feature: Rate Limiting Filter (#83)
* Add Bucket4j dependency to pom.xml for rate-limiting support Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Introduce RateLimitingFilter with Bucket4j for per-IP request throttling Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Work In Progress: Implement per-IP rate-limiting logic in RateLimitingFilter using Bucket4j and add response handling for rate limit exceeded * Finalize and integrate RateLimitingFilter with improved logging, validation, and server cleanup. Add to App pipeline and configure properties. Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Add unit tests for RateLimitingFilter to verify per-IP request handling, rate limit enforcement, and cleanup behavior Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Refactor RateLimitingFilter and tests: simplify comments in filter, improve test method naming, and add validation test Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Add Javadoc to RateLimitingFilter and its tests for improved clarity and documentation Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Remove StaticFilesPlugin from the App pipeline configuration Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Make RateLimitingFilter configuration dynamic and improve response handling in tests Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Add rate-limiting configuration to ConfigLoader and update App pipeline to use dynamic values Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Increase rate-limiting burst capacity to 100 in configuration properties Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> * Add configurable flag to enable/disable rate-limiting in App pipeline and ConfigLoader Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com> --------- Co-authored-by: Mattias Hagström <mattiashagstrommusic@gmail.com>
1 parent 7e5ce75 commit 97381b0

6 files changed

Lines changed: 304 additions & 0 deletions

File tree

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
<artifactId>snakeyaml</artifactId>
4141
<version>2.5</version>
4242
</dependency>
43+
<dependency>
44+
<groupId>com.bucket4j</groupId>
45+
<artifactId>bucket4j_jdk17-core</artifactId>
46+
<version>8.16.1</version>
47+
<scope>compile</scope>
48+
</dependency>
4349
<dependency>
4450
<groupId>org.testcontainers</groupId>
4551
<artifactId>testcontainers</artifactId>

src/main/java/org/juv25d/App.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import org.juv25d.filter.IpFilter;
44
import org.juv25d.filter.LoggingFilter;
5+
import org.juv25d.filter.RateLimitingFilter;
56
import org.juv25d.logging.ServerLogging;
67
import org.juv25d.http.HttpParser;
78
import org.juv25d.plugin.NotFoundPlugin; // New import
@@ -26,8 +27,16 @@ public static void main(String[] args) {
2627
Set.of(),
2728
Set.of()
2829
), 0);
30+
2931
pipeline.addGlobalFilter(new LoggingFilter(), 0);
3032

33+
if (config.isRateLimitingEnabled()) {
34+
pipeline.addGlobalFilter(new RateLimitingFilter(
35+
config.getRequestsPerMinute(),
36+
config.getBurstCapacity()
37+
), 0);
38+
}
39+
3140
// Initialize and configure SimpleRouter
3241
SimpleRouter router = new SimpleRouter();
3342
router.registerPlugin("/", new StaticFilesPlugin()); // Register StaticFilesPlugin for the root path
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package org.juv25d.filter;
2+
3+
import io.github.bucket4j.Bandwidth;
4+
import io.github.bucket4j.Bucket;
5+
import io.github.bucket4j.Refill;
6+
import org.juv25d.http.HttpRequest;
7+
import org.juv25d.http.HttpResponse;
8+
import org.juv25d.logging.ServerLogging;
9+
10+
import java.io.IOException;
11+
import java.nio.charset.StandardCharsets;
12+
import java.time.Duration;
13+
import java.util.Map;
14+
import java.util.concurrent.ConcurrentHashMap;
15+
import java.util.logging.Logger;
16+
17+
/**
18+
* A filter that implements rate limiting for incoming HTTP requests.
19+
* It uses a token bucket algorithm via Bucket4J to limit the number of requests per client IP.
20+
*/
21+
public class RateLimitingFilter implements Filter {
22+
23+
private static final Logger logger = ServerLogging.getLogger();
24+
25+
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
26+
27+
private final long capacity;
28+
private final long refillTokens;
29+
private final Duration refillPeriod;
30+
31+
/**
32+
* Constructs a new RateLimitingFilter.
33+
*
34+
* @param requestsPerMinute the number of requests allowed per minute for each IP
35+
* @param burstCapacity the maximum number of requests that can be handled in a burst
36+
* @throws IllegalArgumentException if requestsPerMinute or burstCapacity is not positive
37+
*/
38+
public RateLimitingFilter(long requestsPerMinute, long burstCapacity) {
39+
if (requestsPerMinute <= 0) {
40+
throw new IllegalArgumentException("requestsPerMinute must be positive");
41+
}
42+
if (burstCapacity <= 0) {
43+
throw new IllegalArgumentException("burstCapacity must be positive");
44+
}
45+
46+
this.capacity = burstCapacity;
47+
this.refillTokens = requestsPerMinute;
48+
this.refillPeriod = Duration.ofMinutes(1);
49+
50+
logger.info(String.format(
51+
"RateLimitingFilter initialized - Limit: %d req/min, Burst: %d",
52+
requestsPerMinute, burstCapacity
53+
));
54+
}
55+
56+
/**
57+
* Applies the rate limiting logic to the incoming request.
58+
* If the rate limit is exceeded, a 429 Too Many Requests response is sent.
59+
*
60+
* @param req the HTTP request
61+
* @param res the HTTP response
62+
* @param chain the filter chain
63+
* @throws IOException if an I/O error occurs
64+
*/
65+
@Override
66+
public void doFilter(HttpRequest req, HttpResponse res, FilterChain chain) throws IOException {
67+
String clientIp = getClientIp(req);
68+
69+
Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createBucket());
70+
71+
if (bucket.tryConsume(1)) {
72+
chain.doFilter(req, res);
73+
} else {
74+
logRateLimitExceeded(clientIp, req.method(), req.path());
75+
sendTooManyRequests(res, clientIp);
76+
}
77+
}
78+
79+
private String getClientIp(HttpRequest req) {
80+
return req.remoteIp();
81+
}
82+
83+
private Bucket createBucket() {
84+
Bandwidth limit = Bandwidth.classic(
85+
capacity,
86+
Refill.intervally(refillTokens, refillPeriod));
87+
88+
return Bucket.builder()
89+
.addLimit(limit)
90+
.build();
91+
}
92+
93+
/**
94+
* Returns the number of currently tracked IP addresses.
95+
*
96+
* @return the number of tracked IP addresses
97+
*/
98+
public int getTrackedIpCount() {
99+
return buckets.size();
100+
}
101+
102+
private void logRateLimitExceeded(String ip, String method, String path) {
103+
logger.warning(String.format(
104+
"Rate limit exceeded - IP: %s, Method: %s, Path: %s",
105+
ip, method, path
106+
));
107+
}
108+
109+
private void sendTooManyRequests(HttpResponse res, String ip) {
110+
byte[] body = ("429 Too Many Requests: Rate limit exceeded for IP " + ip + "\n")
111+
.getBytes(StandardCharsets.UTF_8);
112+
113+
res.setStatusCode(429);
114+
res.setStatusText("Too Many Requests");
115+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
116+
res.setHeader("Content-Length", String.valueOf(body.length));
117+
res.setHeader("Retry-After", "60");
118+
res.setBody(body);
119+
}
120+
121+
/**
122+
* Clears all tracked rate limiting buckets.
123+
*/
124+
@Override
125+
public void destroy() {
126+
buckets.clear();
127+
}
128+
}

src/main/java/org/juv25d/util/ConfigLoader.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ public class ConfigLoader {
1010
private int port;
1111
private String logLevel;
1212
private String rootDirectory;
13+
private long requestsPerMinute;
14+
private long burstCapacity;
15+
private boolean rateLimitingEnabled;
1316

1417
private ConfigLoader() {
1518
loadConfiguration();
@@ -45,6 +48,17 @@ private void loadConfiguration() {
4548
this.logLevel = (String) loggingConfig.get("level");
4649
}
4750

51+
// rate-limiting
52+
Map<String, Object> rateLimitingConfig = (Map<String, Object>) config.get("rate-limiting");
53+
if (rateLimitingConfig != null) {
54+
this.rateLimitingEnabled = (Boolean) rateLimitingConfig.getOrDefault("enabled", true);
55+
this.requestsPerMinute = ((Number) rateLimitingConfig.getOrDefault("requests-per-minute", 60L)).longValue();
56+
this.burstCapacity = ((Number) rateLimitingConfig.getOrDefault("burst-capacity", 100L)).longValue();
57+
} else {
58+
// rate-limiting is disabled if not present in the config file.
59+
this.rateLimitingEnabled = false;
60+
}
61+
4862
} catch (Exception e) {
4963
throw new RuntimeException("Failed to load application config");
5064
}
@@ -61,4 +75,16 @@ public String getLogLevel() {
6175
public String getRootDirectory() {
6276
return rootDirectory;
6377
}
78+
79+
public long getRequestsPerMinute() {
80+
return requestsPerMinute;
81+
}
82+
83+
public long getBurstCapacity() {
84+
return burstCapacity;
85+
}
86+
87+
public boolean isRateLimitingEnabled() {
88+
return rateLimitingEnabled;
89+
}
6490
}

src/main/resources/application-properties.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ server:
44

55
logging:
66
level: INFO
7+
8+
rate-limiting:
9+
enabled: true
10+
requests-per-minute: 60
11+
burst-capacity: 100
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package org.juv25d.filter;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.junit.jupiter.api.extension.ExtendWith;
5+
import org.juv25d.http.HttpRequest;
6+
import org.juv25d.http.HttpResponse;
7+
import org.mockito.Mock;
8+
import org.mockito.junit.jupiter.MockitoExtension;
9+
10+
import java.io.IOException;
11+
12+
import static org.assertj.core.api.Assertions.assertThat;
13+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
14+
import static org.mockito.Mockito.*;
15+
16+
/**
17+
* Tests for the {@link RateLimitingFilter} class.
18+
*/
19+
@ExtendWith(MockitoExtension.class)
20+
class RateLimitingFilterTest {
21+
22+
@Mock
23+
private HttpRequest req;
24+
@Mock
25+
private HttpResponse res;
26+
@Mock
27+
private FilterChain chain;
28+
29+
/**
30+
* Verifies that the filter allows requests when they are within the rate limit.
31+
*/
32+
@Test
33+
void shouldAllowRequest_whenWithinRateLimit() throws IOException {
34+
// Arrange
35+
RateLimitingFilter filter = new RateLimitingFilter(60, 5);
36+
when(req.remoteIp()).thenReturn("127.0.0.1");
37+
38+
// Act
39+
filter.doFilter(req, res, chain);
40+
41+
// Assert
42+
verify(chain, times(1)).doFilter(req, res);
43+
verifyNoMoreInteractions(chain);
44+
verifyNoInteractions(res);
45+
}
46+
47+
/**
48+
* Verifies that the filter blocks requests when the rate limit is exceeded.
49+
*/
50+
@Test
51+
void shouldBlockRequest_whenExceedingRateLimit() throws IOException {
52+
// Arrange
53+
RateLimitingFilter filter = new RateLimitingFilter(60, 5);
54+
when(req.remoteIp()).thenReturn("127.0.0.1");
55+
56+
// Act
57+
for (int i = 0; i < 6; i++) {
58+
filter.doFilter(req, res, chain);
59+
}
60+
61+
// Assert
62+
verify(chain, times(5)).doFilter(req, res);
63+
verifyNoMoreInteractions(chain);
64+
verify(res).setStatusCode(429);
65+
verify(res).setStatusText("Too Many Requests");
66+
verify(res).setHeader("Content-Type", "text/plain; charset=utf-8");
67+
verify(res).setHeader(eq("Content-Length"), any());
68+
verify(res).setHeader("Retry-After", "60");
69+
verify(res).setBody(any());
70+
}
71+
72+
/**
73+
* Verifies that rate limits are tracked independently for different client IPs.
74+
*/
75+
@Test
76+
void shouldAllowRequests_fromDifferentIpsIndependently() throws IOException {
77+
// Arrange
78+
RateLimitingFilter filter = new RateLimitingFilter(60, 5);
79+
HttpRequest req2 = mock(HttpRequest.class);
80+
HttpResponse res2 = mock(HttpResponse.class);
81+
when(req.remoteIp()).thenReturn("127.0.0.1");
82+
when(req2.remoteIp()).thenReturn("192.168.1.1");
83+
84+
// Act
85+
for (int i = 0; i < 6; i++) { // Empty first bucket
86+
filter.doFilter(req, res, chain);
87+
}
88+
for (int i = 0; i < 2; i++) {
89+
filter.doFilter(req2, res2, chain);
90+
}
91+
92+
// Assert
93+
verify(chain, times(7)).doFilter(any(), any());
94+
verify(res).setStatusCode(429);
95+
verifyNoInteractions(res2);
96+
}
97+
98+
/**
99+
* Verifies that the internal bucket map is cleared when the filter is destroyed.
100+
*/
101+
@Test
102+
void shouldClearBuckets_onDestroy() throws IOException {
103+
// Arrange
104+
RateLimitingFilter filter = new RateLimitingFilter(60, 5);
105+
when(req.remoteIp()).thenReturn("127.0.0.1");
106+
107+
filter.doFilter(req, res, chain);
108+
assertThat(filter.getTrackedIpCount()).isEqualTo(1);
109+
110+
// Act
111+
filter.destroy();
112+
113+
// Assert
114+
assertThat(filter.getTrackedIpCount()).isZero();
115+
}
116+
117+
/**
118+
* Verifies that the constructor throws an exception for invalid configuration values.
119+
*/
120+
@Test
121+
void shouldThrowException_whenInvalidConfiguration() {
122+
// Act & Assert
123+
assertThatThrownBy(() -> new RateLimitingFilter(0, 5))
124+
.isInstanceOf(IllegalArgumentException.class);
125+
126+
// Act & Assert
127+
assertThatThrownBy(() -> new RateLimitingFilter(60, 0))
128+
.isInstanceOf(IllegalArgumentException.class);
129+
}
130+
}

0 commit comments

Comments
 (0)