-
-
Notifications
You must be signed in to change notification settings - Fork 735
Add rate limiter exercise #3008
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
Changes from 34 commits
0513d96
bfe4dbb
81c6728
ae33983
4f5abf3
4f0c836
a697b81
8092b9a
87fc175
4479173
d9520b7
63ea8a3
9f6a8bf
a616a10
9f077d0
89ec426
9657afc
98d6103
43610f1
a61ef4d
50b280c
5971500
69c1ba9
6f27c12
368d7ea
87c3d3c
d451060
67baa14
aba22d5
715f181
efadb02
8dc4059
b6dea08
ae8d97e
60736bb
25a2876
d418567
baf39d2
7907757
6100e11
4d9b1bf
6ccd97a
b839de1
a8a6048
6b8e310
b3667bc
0b158f7
d5b13e9
5e4cc5c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
kahgoh marked this conversation as resolved.
Show resolved
Hide resolved
kahgoh marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
Your task is to build a fixed‑window rate limiter. | ||
Check failure on line 1 in exercises/practice/rate-limiter/.docs/instructions.md
|
||
andreatanky marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
Imagine a single server connected to one or more clients. | ||
A client sends a request, the server does some work, and returns a response. | ||
But processing takes time. | ||
If a client sends too many requests too quickly, the server can become overwhelmed — everything slows down or fails. | ||
|
||
A rate limiter is a small component that decides whether to allow or reject a request based on how frequently that client has been making requests. | ||
Different strategies exist; in this exercise you’ll implement a fixed‑window rate limiter. | ||
|
||
Fixed‑window rate limiting groups time into equal‑length windows (for example, every 10 seconds) and allows up to a certain number of requests within each window for each client. | ||
Once the window resets, the allowance refreshes for the next window. | ||
Each client is tracked separately, so another client can make requests within that same period. | ||
|
||
For example, consider a rate limiter configured to limit 2 requests per 10 seconds per client. | ||
Lets say client A sends a request. | ||
Check failure on line 16 in exercises/practice/rate-limiter/.docs/instructions.md
|
||
- Being its first request, the request is permitted. | ||
Check failure on line 17 in exercises/practice/rate-limiter/.docs/instructions.md
|
||
- A second request within 10 seconds after the first one is also permitted. | ||
- However, further requests after that would be denied _until_ at least 10 seconds has elapsed since the first request. | ||
- If a second client sends its first request within 10 seconds with of the first client's first request, it would also be permitted, _regardless_ of whether the first client has sent a second request. | ||
Check failure on line 20 in exercises/practice/rate-limiter/.docs/instructions.md
|
||
andreatanky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
kahgoh marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
{ | ||
"authors": ["andreatanky"], | ||
"files": { | ||
"solution": [ | ||
"src/main/java/RateLimiter.java" | ||
], | ||
"test": [ | ||
"src/test/java/RateLimiterTest.java" | ||
], | ||
"example": [ | ||
".meta/src/reference/java/RateLimiter.java", | ||
".meta/src/reference/java/TimeSource.java" | ||
], | ||
"editor": [ | ||
"src/main/java/TimeSource.java" | ||
] | ||
}, | ||
"blurb": "Practice stateful logic and time handling by implementing a fixed-window rate limiter" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
import java.time.Duration; | ||
import java.time.Instant; | ||
import java.util.HashMap; | ||
import java.util.Map; | ||
|
||
public class RateLimiter<K> { | ||
|
||
private static final class WindowState { | ||
Instant windowStart; | ||
int usedCount; | ||
|
||
WindowState(Instant windowStart, int usedCount) { | ||
this.windowStart = windowStart; | ||
this.usedCount = usedCount; | ||
} | ||
} | ||
|
||
private final int limit; | ||
private final Duration windowSize; | ||
private final TimeSource timeSource; | ||
private final Map<K, WindowState> states = new HashMap<>(); | ||
|
||
public RateLimiter(int limit, Duration windowSize, TimeSource timeSource) { | ||
this.limit = limit; | ||
this.windowSize = windowSize; | ||
this.timeSource = timeSource; | ||
} | ||
|
||
public boolean allow(K clientId) { | ||
Instant now = timeSource.now(); | ||
|
||
WindowState s = states.get(clientId); | ||
if (s == null) { | ||
s = new WindowState(now, 0); | ||
states.put(clientId, s); | ||
} | ||
|
||
if (!now.isBefore(s.windowStart.plus(windowSize))) { | ||
s.windowStart = now; | ||
s.usedCount = 0; | ||
} | ||
|
||
if (s.usedCount < limit) { | ||
s.usedCount += 1; | ||
return true; | ||
} | ||
return false; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import java.time.Duration; | ||
import java.time.Instant; | ||
|
||
public class TimeSource { | ||
private Instant now; | ||
|
||
public TimeSource(Instant start) { | ||
this.now = start; | ||
} | ||
|
||
public Instant now() { | ||
return now; | ||
} | ||
|
||
public void advance(Duration d) { | ||
this.now = this.now.plus(d); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
plugins { | ||
id "java" | ||
} | ||
|
||
repositories { | ||
mavenCentral() | ||
} | ||
|
||
dependencies { | ||
testImplementation platform("org.junit:junit-bom:5.10.0") | ||
testImplementation "org.junit.jupiter:junit-jupiter" | ||
testImplementation "org.assertj:assertj-core:3.25.1" | ||
|
||
testRuntimeOnly "org.junit.platform:junit-platform-launcher" | ||
} | ||
|
||
test { | ||
useJUnitPlatform() | ||
|
||
testLogging { | ||
exceptionFormat = "full" | ||
showStandardStreams = true | ||
events = ["passed", "failed", "skipped"] | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import java.time.Duration; | ||
import java.time.Instant; | ||
|
||
public class RateLimiter<K> { | ||
kahgoh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
public RateLimiter(int limit, Duration windowSize, TimeSource timeSource) { | ||
|
||
andreatanky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
} | ||
|
||
public boolean allow(K clientId) { | ||
throw new UnsupportedOperationException("Delete this statement and write your own implementation."); | ||
} | ||
} |
kahgoh marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import java.time.Duration; | ||
import java.time.Instant; | ||
|
||
andreatanky marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/** | ||
* NOTE: There is no need to change this file and is treated as read only by the Exercism test runners. | ||
*/ | ||
public class TimeSource { | ||
private Instant now; | ||
|
||
public TimeSource(Instant start) { | ||
this.now = start; | ||
} | ||
|
||
public Instant now() { | ||
return now; | ||
} | ||
|
||
public void advance(Duration d) { | ||
this.now = this.now.plus(d); | ||
} | ||
} |
kahgoh marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,185 @@ | ||||||||||||
import org.junit.jupiter.api.Disabled; | ||||||||||||
import org.junit.jupiter.api.Test; | ||||||||||||
import java.time.Duration; | ||||||||||||
import java.time.Instant; | ||||||||||||
import java.util.UUID; | ||||||||||||
|
||||||||||||
import static org.assertj.core.api.Assertions.assertThat; | ||||||||||||
|
||||||||||||
class RateLimiterTest { | ||||||||||||
|
||||||||||||
@Test | ||||||||||||
void allowsUpToLimit() { | ||||||||||||
Comment on lines
12
to
14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We've started putting
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated with the tests with |
||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH); | ||||||||||||
RateLimiter<String> limiter = new RateLimiter<>(3, Duration.ofNanos(10_000L), clock); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
// Advance minimally to model time passage between requests | ||||||||||||
andreatanky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||
clock.advance(Duration.ofNanos(1L)); | ||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
assertThat(limiter.allow("A")).isFalse(); | ||||||||||||
} | ||||||||||||
|
||||||||||||
@Disabled("Remove to run test") | ||||||||||||
@Test | ||||||||||||
void denyCloseToBoundary() { | ||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH); | ||||||||||||
RateLimiter<String> limiter = new RateLimiter<>(2, Duration.ofNanos(10_000L), clock); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
assertThat(limiter.allow("A")).isFalse(); | ||||||||||||
|
||||||||||||
// Just before boundary: still same window | ||||||||||||
clock.advance(Duration.ofNanos(9_999L)); | ||||||||||||
assertThat(limiter.allow("A")).isFalse(); | ||||||||||||
} | ||||||||||||
|
||||||||||||
@Disabled("Remove to run test") | ||||||||||||
@Test | ||||||||||||
void allowsNewBoundary() { | ||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH); | ||||||||||||
RateLimiter<String> limiter = new RateLimiter<>(2, Duration.ofNanos(10_000L), clock); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
assertThat(limiter.allow("A")).isFalse(); | ||||||||||||
|
||||||||||||
// At exact boundary: new window | ||||||||||||
clock.advance(Duration.ofNanos(10_000L)); | ||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
} | ||||||||||||
|
||||||||||||
@Disabled("Remove to run test") | ||||||||||||
@Test | ||||||||||||
kahgoh marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||
void continuesCountingWithinWindowAfterBoundaryReset() { | ||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH); | ||||||||||||
RateLimiter<String> limiter = new RateLimiter<>(2, Duration.ofNanos(5_000L), clock); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow("key")).isTrue(); | ||||||||||||
clock.advance(Duration.ofNanos(1L)); | ||||||||||||
assertThat(limiter.allow("key")).isTrue(); | ||||||||||||
assertThat(limiter.allow("key")).isFalse(); | ||||||||||||
|
||||||||||||
// Jump to next window | ||||||||||||
clock.advance(Duration.ofNanos(5_000L)); | ||||||||||||
assertThat(limiter.allow("key")).isTrue(); | ||||||||||||
assertThat(limiter.allow("key")).isTrue(); | ||||||||||||
assertThat(limiter.allow("key")).isFalse(); | ||||||||||||
} | ||||||||||||
|
||||||||||||
@Disabled("Remove to run test") | ||||||||||||
@Test | ||||||||||||
void separateKeysHaveIndependentCountersAndWindows() { | ||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH.plusNanos(42L)); | ||||||||||||
RateLimiter<String> limiter = new RateLimiter<>(1, Duration.ofNanos(100L), clock); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
kahgoh marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||
// Advance a tiny amount to model time moving between requests | ||||||||||||
clock.advance(Duration.ofNanos(1L)); | ||||||||||||
assertThat(limiter.allow("A")).isFalse(); | ||||||||||||
assertThat(limiter.allow("B")).isTrue(); // independent key | ||||||||||||
assertThat(limiter.allow("B")).isFalse(); | ||||||||||||
|
||||||||||||
clock.advance(Duration.ofNanos(100L)); // new window for both at boundary | ||||||||||||
assertThat(limiter.allow("A")).isTrue(); | ||||||||||||
assertThat(limiter.allow("B")).isTrue(); | ||||||||||||
} | ||||||||||||
|
||||||||||||
@Disabled("Remove to run test") | ||||||||||||
@Test | ||||||||||||
void longGapsResetWindow() { | ||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH.plusNanos(1_000L)); | ||||||||||||
RateLimiter<String> limiter = new RateLimiter<>(2, Duration.ofNanos(50L), clock); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow("X")).isTrue(); | ||||||||||||
clock.advance(Duration.ofNanos(1L)); | ||||||||||||
assertThat(limiter.allow("X")).isTrue(); | ||||||||||||
assertThat(limiter.allow("X")).isFalse(); | ||||||||||||
|
||||||||||||
// Advance several windows worth | ||||||||||||
clock.advance(Duration.ofNanos(1_000L)); | ||||||||||||
assertThat(limiter.allow("X")).isTrue(); | ||||||||||||
assertThat(limiter.allow("X")).isTrue(); | ||||||||||||
assertThat(limiter.allow("X")).isFalse(); | ||||||||||||
} | ||||||||||||
|
||||||||||||
@Disabled("Remove to run test") | ||||||||||||
@Test | ||||||||||||
void exactBoundaryIsNewWindowEveryTime() { | ||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH); | ||||||||||||
RateLimiter<String> limiter = new RateLimiter<>(1, Duration.ofNanos(10L), clock); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow("k")).isTrue(); | ||||||||||||
assertThat(limiter.allow("k")).isFalse(); | ||||||||||||
|
||||||||||||
// Move exactly to boundary repeatedly; each time should allow once | ||||||||||||
for (int i = 0; i < 5; i++) { | ||||||||||||
clock.advance(Duration.ofNanos(10L)); | ||||||||||||
assertThat(limiter.allow("k")).isTrue(); | ||||||||||||
assertThat(limiter.allow("k")).isFalse(); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
|
||||||||||||
|
||||||||||||
@Disabled("Remove to run test") | ||||||||||||
@Test | ||||||||||||
void supportsUuidKeys() { | ||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH); | ||||||||||||
// Use a seconds-long window and advance in smaller units to mix Duration usage | ||||||||||||
andreatanky marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||
RateLimiter<UUID> limiter = new RateLimiter<>(1, Duration.ofSeconds(1L), clock); | ||||||||||||
|
||||||||||||
UUID a = UUID.fromString("00000000-0000-0000-0000-000000000001"); | ||||||||||||
UUID b = UUID.fromString("00000000-0000-0000-0000-000000000002"); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow(a)).isTrue(); | ||||||||||||
assertThat(limiter.allow(a)).isFalse(); | ||||||||||||
// Advance slightly so the second client's window anchors later | ||||||||||||
clock.advance(Duration.ofMillis(1L)); | ||||||||||||
assertThat(limiter.allow(b)).isTrue(); | ||||||||||||
assertThat(limiter.allow(b)).isFalse(); | ||||||||||||
|
||||||||||||
// Advance exactly one second to hit the window boundary | ||||||||||||
clock.advance(Duration.ofSeconds(1L)); | ||||||||||||
assertThat(limiter.allow(a)).isTrue(); | ||||||||||||
assertThat(limiter.allow(b)).isTrue(); | ||||||||||||
} | ||||||||||||
|
||||||||||||
@Disabled("Remove to run test") | ||||||||||||
@Test | ||||||||||||
void supportsIntegerKeys() { | ||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH); | ||||||||||||
RateLimiter<Integer> limiter = new RateLimiter<>(1, Duration.ofNanos(100L), clock); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow(42)).isTrue(); | ||||||||||||
assertThat(limiter.allow(42)).isFalse(); | ||||||||||||
clock.advance(Duration.ofNanos(1L)); | ||||||||||||
assertThat(limiter.allow(84)).isTrue(); // independent key of different type | ||||||||||||
assertThat(limiter.allow(84)).isFalse(); | ||||||||||||
|
||||||||||||
clock.advance(Duration.ofNanos(100L)); // boundary resets both | ||||||||||||
assertThat(limiter.allow(42)).isTrue(); | ||||||||||||
assertThat(limiter.allow(84)).isTrue(); | ||||||||||||
} | ||||||||||||
|
||||||||||||
@Disabled("Remove to run test") | ||||||||||||
@Test | ||||||||||||
void supportsLongKeys() { | ||||||||||||
TimeSource clock = new TimeSource(Instant.EPOCH); | ||||||||||||
RateLimiter<Long> limiter = new RateLimiter<>(2, Duration.ofNanos(50L), clock); | ||||||||||||
|
||||||||||||
assertThat(limiter.allow(1L)).isTrue(); | ||||||||||||
assertThat(limiter.allow(1L)).isTrue(); | ||||||||||||
assertThat(limiter.allow(1L)).isFalse(); | ||||||||||||
|
||||||||||||
clock.advance(Duration.ofNanos(1L)); | ||||||||||||
assertThat(limiter.allow(2L)).isTrue(); // independent key | ||||||||||||
assertThat(limiter.allow(2L)).isTrue(); | ||||||||||||
assertThat(limiter.allow(2L)).isFalse(); | ||||||||||||
|
||||||||||||
clock.advance(Duration.ofNanos(50L)); | ||||||||||||
assertThat(limiter.allow(1L)).isTrue(); | ||||||||||||
assertThat(limiter.allow(2L)).isTrue(); | ||||||||||||
} | ||||||||||||
} |
Uh oh!
There was an error while loading. Please reload this page.