Skip to content

Commit 36c9ae2

Browse files
author
games647
committed
Fix rate limiter
Time reported by nanoTime is arbitrarily and could include negative numbers
1 parent e0f823c commit 36c9ae2

File tree

4 files changed

+109
-16
lines changed

4 files changed

+109
-16
lines changed

core/src/main/java/com/github/games647/fastlogin/core/RateLimiter.java

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
*/
2626
package com.github.games647.fastlogin.core;
2727

28+
import com.google.common.base.Ticker;
29+
2830
import java.util.Arrays;
2931

3032
/**
@@ -33,16 +35,22 @@
3335
*/
3436
public class RateLimiter {
3537

38+
private final Ticker ticker;
39+
3640
private final long[] requests;
3741
private final long expireTime;
3842
private int position;
3943

40-
public RateLimiter(int maxLimit, long expireTime) {
44+
public RateLimiter(Ticker ticker, int maxLimit, long expireTime) {
45+
this.ticker = ticker;
46+
4147
this.requests = new long[maxLimit];
4248
this.expireTime = expireTime;
4349

44-
// fill the array with the lowest values, so that the first uninitialized values will always expire
45-
Arrays.fill(requests, Long.MIN_VALUE);
50+
// fill the array with expired entry, because nanoTime could overflow and include negative numbers
51+
long nowMilli = ticker.read() / 1_000_000;
52+
long initialVal = nowMilli - expireTime;
53+
Arrays.fill(requests, initialVal);
4654
}
4755

4856
/**
@@ -52,15 +60,12 @@ public RateLimiter(int maxLimit, long expireTime) {
5260
*/
5361
public boolean tryAcquire() {
5462
// current time millis is not monotonic - it can jump back depending on user choice or NTP
55-
long now = System.nanoTime() / 1_000_000;
56-
57-
// after this the request should be expired
58-
long toBeExpired = now - expireTime;
63+
long nowMilli = ticker.read() / 1_000_000;
5964
synchronized (this) {
6065
// having synchronized will limit the amount of concurrency a lot
6166
long oldest = requests[position];
62-
if (oldest < toBeExpired) {
63-
requests[position] = now;
67+
if (nowMilli - oldest >= expireTime) {
68+
requests[position] = nowMilli;
6469
position = (position + 1) % requests.length;
6570
return true;
6671
}

core/src/main/java/com/github/games647/fastlogin/core/shared/FastLoginCore.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import com.github.games647.fastlogin.core.storage.MySQLStorage;
3636
import com.github.games647.fastlogin.core.storage.SQLStorage;
3737
import com.github.games647.fastlogin.core.storage.SQLiteStorage;
38+
import com.google.common.base.Ticker;
3839
import com.google.common.net.HostAndPort;
3940
import com.zaxxer.hikari.HikariConfig;
4041

@@ -122,7 +123,7 @@ public void load() {
122123
expireTime = MAX_EXPIRE_RATE;
123124
}
124125

125-
rateLimiter = new RateLimiter(maxCon, expireTime);
126+
rateLimiter = new RateLimiter(Ticker.systemTicker(), maxCon, expireTime);
126127
Set<Proxy> proxies = config.getStringList("proxies")
127128
.stream()
128129
.map(HostAndPort::fromString)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.github.games647.fastlogin.core;
2+
3+
import com.google.common.base.Ticker;
4+
5+
import java.time.Duration;
6+
7+
public class FakeTicker extends Ticker {
8+
9+
private long timestamp;
10+
11+
public FakeTicker(long initial) {
12+
timestamp = initial;
13+
}
14+
15+
@Override
16+
public long read() {
17+
return timestamp;
18+
}
19+
20+
public void add(Duration duration) {
21+
timestamp += duration.toNanos();
22+
}
23+
}

core/src/test/java/com/github/games647/fastlogin/core/RateLimiterTest.java

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
*/
2626
package com.github.games647.fastlogin.core;
2727

28+
import java.time.Duration;
2829
import java.util.concurrent.TimeUnit;
2930

3031
import org.junit.Test;
@@ -43,14 +44,34 @@ public class RateLimiterTest {
4344
public void allowExpire() throws InterruptedException {
4445
int size = 3;
4546

47+
FakeTicker ticker = new FakeTicker(5_000_000L);
48+
49+
// run twice the size to fill it first and then test it
50+
RateLimiter rateLimiter = new RateLimiter(ticker, size, 0);
51+
for (int i = 0; i < size; i++) {
52+
assertTrue("Filling up", rateLimiter.tryAcquire());
53+
}
54+
55+
for (int i = 0; i < size; i++) {
56+
ticker.add(Duration.ofSeconds(1));
57+
assertTrue("Should be expired", rateLimiter.tryAcquire());
58+
}
59+
}
60+
61+
@Test
62+
public void allowExpireNegative() throws InterruptedException {
63+
int size = 3;
64+
65+
FakeTicker ticker = new FakeTicker(-5_000_000L);
66+
4667
// run twice the size to fill it first and then test it
47-
RateLimiter rateLimiter = new RateLimiter(size, 0);
68+
RateLimiter rateLimiter = new RateLimiter(ticker, size, 0);
4869
for (int i = 0; i < size; i++) {
4970
assertTrue("Filling up", rateLimiter.tryAcquire());
5071
}
5172

5273
for (int i = 0; i < size; i++) {
53-
Thread.sleep(1);
74+
ticker.add(Duration.ofSeconds(1));
5475
assertTrue("Should be expired", rateLimiter.tryAcquire());
5576
}
5677
}
@@ -62,8 +83,28 @@ public void allowExpire() throws InterruptedException {
6283
public void shouldBlock() {
6384
int size = 3;
6485

86+
FakeTicker ticker = new FakeTicker(5_000_000L);
87+
88+
// fill the size
89+
RateLimiter rateLimiter = new RateLimiter(ticker, size, TimeUnit.SECONDS.toMillis(30));
90+
for (int i = 0; i < size; i++) {
91+
assertTrue("Filling up", rateLimiter.tryAcquire());
92+
}
93+
94+
assertFalse("Should be full and no entry should be expired", rateLimiter.tryAcquire());
95+
}
96+
97+
/**
98+
* Too many requests
99+
*/
100+
@Test
101+
public void shouldBlockNegative() {
102+
int size = 3;
103+
104+
FakeTicker ticker = new FakeTicker(-5_000_000L);
105+
65106
// fill the size
66-
RateLimiter rateLimiter = new RateLimiter(size, TimeUnit.SECONDS.toMillis(30));
107+
RateLimiter rateLimiter = new RateLimiter(ticker, size, TimeUnit.SECONDS.toMillis(30));
67108
for (int i = 0; i < size; i++) {
68109
assertTrue("Filling up", rateLimiter.tryAcquire());
69110
}
@@ -76,17 +117,40 @@ public void shouldBlock() {
76117
*/
77118
@Test
78119
public void blockedNotAdded() throws InterruptedException {
120+
FakeTicker ticker = new FakeTicker(5_000_000L);
121+
122+
// fill the size - 100ms should be reasonable high
123+
RateLimiter rateLimiter = new RateLimiter(ticker, 1, 100);
124+
assertTrue("Filling up", rateLimiter.tryAcquire());
125+
126+
ticker.add(Duration.ofMillis(50));
127+
128+
// still is full - should fail
129+
assertFalse("Expired too early", rateLimiter.tryAcquire());
130+
131+
// wait the remaining time and add a threshold, because
132+
ticker.add(Duration.ofMillis(50));
133+
assertTrue("Request not released", rateLimiter.tryAcquire());
134+
}
135+
136+
/**
137+
* Blocked attempts shouldn't replace existing ones.
138+
*/
139+
@Test
140+
public void blockedNotAddedNegative() throws InterruptedException {
141+
FakeTicker ticker = new FakeTicker(-5_000_000L);
142+
79143
// fill the size - 100ms should be reasonable high
80-
RateLimiter rateLimiter = new RateLimiter(1, 100);
144+
RateLimiter rateLimiter = new RateLimiter(ticker, 1, 100);
81145
assertTrue("Filling up", rateLimiter.tryAcquire());
82146

83-
Thread.sleep(50);
147+
ticker.add(Duration.ofMillis(50));
84148

85149
// still is full - should fail
86150
assertFalse("Expired too early", rateLimiter.tryAcquire());
87151

88152
// wait the remaining time and add a threshold, because
89-
Thread.sleep(50 + THRESHOLD_MILLI);
153+
ticker.add(Duration.ofMillis(50));
90154
assertTrue("Request not released", rateLimiter.tryAcquire());
91155
}
92156
}

0 commit comments

Comments
 (0)