Skip to content

Commit ea78c13

Browse files
authored
Merge pull request #80 from oslabs-beta/jd/slide
Sliding Window Counter Functionality Complete
2 parents 3f9ad17 + 2e3741d commit ea78c13

File tree

4 files changed

+128
-49
lines changed

4 files changed

+128
-49
lines changed

src/@types/rateLimit.d.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,19 @@ export interface TokenBucketOptions {
4747
refillRate: number;
4848
}
4949

50+
/**
51+
* @type {number} windowSize - Size of each fixed window and the rolling window
52+
* @type {number} capacity - Number of tokens a window can hold
53+
*/
54+
export interface SlidingWindowCounterOptions {
55+
windowSize: number;
56+
capacity: number;
57+
}
58+
5059
// TODO: This will be a union type where we can specify Option types for other Rate Limiters
5160
// Record<string, never> represents the empty object for alogorithms that don't require settings
5261
// and might be able to be removed in the future.
53-
export type RateLimiterOptions = TokenBucketOptions | Record<string, never>;
62+
export type RateLimiterOptions =
63+
| TokenBucketOptions
64+
| SlidingWindowCounterOptions
65+
| Record<string, never>;

src/middleware/rateLimiterSetup.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Redis from 'ioredis';
2-
import { RateLimiterOptions, RateLimiterSelection } from '../@types/rateLimit';
2+
import { RateLimiterOptions, RateLimiterSelection, TokenBucketOptions } from '../@types/rateLimit';
3+
import SlidingWindowCounter from '../rateLimiters/slidingWindowCounter';
34
import TokenBucket from '../rateLimiters/tokenBucket';
45

56
/**
@@ -19,6 +20,8 @@ export default function setupRateLimiter(
1920
switch (selection) {
2021
case 'TOKEN_BUCKET':
2122
// todo validate options
23+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
24+
// @ts-ignore
2225
return new TokenBucket(options.bucketSize, options.refillRate, client);
2326
break;
2427
case 'LEAKY_BUCKET':
@@ -31,7 +34,9 @@ export default function setupRateLimiter(
3134
throw new Error('Sliding Window Log has not be implemented.');
3235
break;
3336
case 'SLIDING_WINDOW_COUNTER':
34-
throw new Error('Sliding Window Counter algonithm has not be implemented.');
37+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
38+
// @ts-ignore
39+
return new SlidingWindowCounter(options.windowSize, options.capacity, client);
3540
break;
3641
default:
3742
// typescript should never let us invoke this function with anything other than the options above

src/rateLimiters/slidingWindowCounter.ts

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import { RateLimiter, RateLimiterResponse, RedisWindow } from '../@types/rateLim
99
* takeup in each.
1010
*
1111
* Whenever a user makes a request the following steps are performed:
12-
* 1. Fixed minute windows are defined along with redis caches if previously undefined.
13-
* 2. Rolling minute windows are defined or updated based on the timestamp of the new request.
12+
* 1. Fixed windows are defined along with redis caches if previously undefined.
13+
* 2. Rolling windows are defined or updated based on the timestamp of the new request.
1414
* 3. Counter of the current fixed window is updated with the new request's token usage.
1515
* 4. If a new minute interval is reached, the averaging formula is run to prevent fixed window's flaw
1616
* of flooded requests around window borders
17-
* (ex. 10 token capacity: 1m59s 10 reqs 2m2s 10 reqs)
17+
* (ex. 1m windows, 10 token capacity: 1m59s 10 reqs 2m2s 10 reqs)
1818
*/
1919
class SlidingWindowCounter implements RateLimiter {
2020
private windowSize: number;
@@ -24,10 +24,10 @@ class SlidingWindowCounter implements RateLimiter {
2424
private client: Redis;
2525

2626
/**
27-
* Create a new instance of a TokenBucket rate limiter that can be connected to any database store
28-
* @param windowSize - size of each window in milliseconds (fixed and rolling)
29-
* @param capacity - max capacity of tokens allowed per fixed window
30-
* @param client - redis client where rate limiter will cache information
27+
* Create a new instance of a SlidingWindowCounter rate limiter that can be connected to any database store
28+
* @param windowSize size of each window in milliseconds (fixed and rolling)
29+
* @param capacity max capacity of tokens allowed per fixed window
30+
* @param client redis client where rate limiter will cache information
3131
*/
3232
constructor(windowSize: number, capacity: number, client: Redis) {
3333
this.windowSize = windowSize;
@@ -38,12 +38,13 @@ class SlidingWindowCounter implements RateLimiter {
3838
}
3939

4040
/**
41-
* @function processRequest - current timestamp and number of tokens required for
42-
* the request to go through are passed in. We first check if a window exists in the redis
43-
* cache.
41+
* @function processRequest - Sliding window counter algorithm to allow or block
42+
* based on the depth/complexity (in amount of tokens) of incoming requests.
4443
*
45-
* If not, then fixedWindowStart is set as the current timestamp, and currentTokens
46-
* is checked against capacity. If we have enough capacity for the request, we return
44+
* First, checks if a window exists in the redis cache.
45+
*
46+
* If not, then `fixedWindowStart` is set as the current timestamp, and `currentTokens`
47+
* is checked against `capacity`. If enough room exists for the request, returns
4748
* success as true and tokens as how many tokens remain in the current fixed window.
4849
*
4950
* If a window does exist in the cache, we first check if the timestamp is greater than
@@ -66,6 +67,8 @@ class SlidingWindowCounter implements RateLimiter {
6667
* @param {number} timestamp - time the request was recieved
6768
* @param {number} [tokens=1] - complexity of the query for throttling requests
6869
* @return {*} {Promise<RateLimiterResponse>}
70+
* RateLimiterResponse: {success: boolean, tokens: number}
71+
* (tokens represents the remaining available capacity of the window)
6972
* @memberof SlidingWindowCounter
7073
*/
7174
async processRequest(
@@ -79,31 +82,90 @@ class SlidingWindowCounter implements RateLimiter {
7982
// attempt to get the value for the uuid from the redis cache
8083
const windowJSON = await this.client.get(uuid);
8184

82-
// // if the response is null, we need to create a window for the user
83-
// if (windowJSON === null) {
84-
// // rolling window is 1 minute long
85-
// const rollingWindowEnd = timestamp + 60000;
86-
87-
// // grabs the actual minute from the timestamp to create fixed window
88-
// const fixedWindowStart = timestamp - (timestamp % 10000);
89-
// const fixedWindowEnd = fixedWindowStart + 60000;
90-
91-
// const newUserWindow: RedisWindow = {
92-
// // conditionally set tokens depending on how many are requested compared to the capacity
93-
// tokens: tokens > this.capacity ? this.capacity : this.capacity - tokens,
94-
// timestamp,
95-
// };
96-
97-
// // reject the request, not enough tokens could even be in the bucket
98-
// if (tokens > this.capacity) {
99-
// await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow));
100-
// return { success: false, tokens: this.capacity };
101-
// }
102-
// await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow));
103-
// return { success: true, tokens: newUserWindow.tokens };
104-
// }
105-
106-
return { success: true, tokens: 0 };
85+
// if the response is null, we need to create a window for the user
86+
if (windowJSON === null) {
87+
const newUserWindow: RedisWindow = {
88+
// current and previous tokens represent how many tokens are in each window
89+
currentTokens: tokens <= this.capacity ? tokens : 0,
90+
previousTokens: 0,
91+
fixedWindowStart: timestamp,
92+
};
93+
94+
if (tokens <= this.capacity) {
95+
await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow));
96+
return { success: true, tokens: this.capacity - newUserWindow.currentTokens };
97+
}
98+
99+
await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow));
100+
// tokens property represents how much capacity remains
101+
return { success: false, tokens: this.capacity };
102+
}
103+
104+
// if the cache is populated
105+
106+
const window: RedisWindow = await JSON.parse(windowJSON);
107+
108+
const updatedUserWindow: RedisWindow = {
109+
currentTokens: window.currentTokens,
110+
previousTokens: window.previousTokens,
111+
fixedWindowStart: window.fixedWindowStart,
112+
};
113+
114+
// if request time is in a new window
115+
if (window.fixedWindowStart && timestamp >= window.fixedWindowStart + this.windowSize) {
116+
// if more than one window was skipped
117+
if (timestamp >= window.fixedWindowStart + this.windowSize * 2) {
118+
// if one or more windows was skipped, reset new window to be at current timestamp
119+
updatedUserWindow.previousTokens = 0;
120+
updatedUserWindow.currentTokens = 0;
121+
updatedUserWindow.fixedWindowStart = timestamp;
122+
} else {
123+
updatedUserWindow.previousTokens = updatedUserWindow.currentTokens;
124+
updatedUserWindow.currentTokens = 0;
125+
updatedUserWindow.fixedWindowStart = window.fixedWindowStart + this.windowSize;
126+
}
127+
}
128+
129+
// assigned to avoid TS error, this var will never be used as 0
130+
// var is declared here so that below can be inside a conditional for efficiency's sake
131+
let rollingWindowProportion = 0;
132+
let previousRollingTokens = 0;
133+
134+
if (updatedUserWindow.fixedWindowStart && updatedUserWindow.previousTokens) {
135+
// proportion of rolling window present in previous window
136+
rollingWindowProportion =
137+
(this.windowSize - (timestamp - updatedUserWindow.fixedWindowStart)) /
138+
this.windowSize;
139+
140+
// remove unecessary decimals, 0.xx is enough
141+
// rollingWindowProportion -= rollingWindowProportion % 0.01;
142+
143+
// # of tokens present in rolling & previous window
144+
previousRollingTokens = Math.floor(
145+
updatedUserWindow.previousTokens * rollingWindowProportion
146+
);
147+
}
148+
149+
// # of tokens present in rolling and/or current window
150+
// if previous tokens is null, previousRollingTokens will be 0
151+
const rollingTokens = updatedUserWindow.currentTokens + previousRollingTokens;
152+
153+
// if request is allowed
154+
if (tokens + rollingTokens <= this.capacity) {
155+
updatedUserWindow.currentTokens += tokens;
156+
await this.client.setex(uuid, keyExpiry, JSON.stringify(updatedUserWindow));
157+
return {
158+
success: true,
159+
tokens: this.capacity - (updatedUserWindow.currentTokens + previousRollingTokens),
160+
};
161+
}
162+
163+
// if request is blocked
164+
await this.client.setex(uuid, keyExpiry, JSON.stringify(updatedUserWindow));
165+
return {
166+
success: false,
167+
tokens: this.capacity - (updatedUserWindow.currentTokens + previousRollingTokens),
168+
};
107169
}
108170

109171
/**

test/rateLimiters/slidingWindowCounter.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async function setTokenCountInClient(
3535
await redisClient.set(uuid, JSON.stringify(value));
3636
}
3737

38-
xdescribe('Test SlidingWindowCounter Rate Limiter', () => {
38+
describe('Test SlidingWindowCounter Rate Limiter', () => {
3939
beforeEach(async () => {
4040
// init a mock redis cache
4141
client = new RedisMock();
@@ -249,15 +249,15 @@ xdescribe('Test SlidingWindowCounter Rate Limiter', () => {
249249
const result = await limiter.processRequest(
250250
user4,
251251
timestamp + WINDOW_SIZE * 1.99,
252-
4
252+
10
253253
);
254254
expect(result.tokens).toBe(0);
255255
expect(result.success).toBe(true);
256256

257257
// currentTokens (in current fixed window): 4
258258
// previousTokens (in previous fixed window): 8
259259
const count1 = await getWindowFromClient(client, user4);
260-
expect(count1.currentTokens).toBe(4);
260+
expect(count1.currentTokens).toBe(10);
261261
expect(count1.previousTokens).toBe(8);
262262
});
263263
});
@@ -304,7 +304,7 @@ xdescribe('Test SlidingWindowCounter Rate Limiter', () => {
304304

305305
// 3 + 8 * 1 = 11, above capacity (request should be blocked)
306306
const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 3);
307-
expect(result.tokens).toBe(10);
307+
expect(result.tokens).toBe(2);
308308
expect(result.success).toBe(false);
309309

310310
// currentTokens (in current fixed window): 0
@@ -332,7 +332,7 @@ xdescribe('Test SlidingWindowCounter Rate Limiter', () => {
332332
timestamp + WINDOW_SIZE * 1.25,
333333
5
334334
);
335-
expect(result.tokens).toBe(10);
335+
expect(result.tokens).toBe(4);
336336
expect(result.success).toBe(false);
337337

338338
// currentTokens (in current fixed window): 0
@@ -358,7 +358,7 @@ xdescribe('Test SlidingWindowCounter Rate Limiter', () => {
358358

359359
// 7 + 8 * .5 = 11, over capacity (request should be blocked)
360360
const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.5, 7);
361-
expect(result.tokens).toBe(10);
361+
expect(result.tokens).toBe(6);
362362
expect(result.success).toBe(false);
363363

364364
// currentTokens (in current fixed window): 0
@@ -383,7 +383,7 @@ xdescribe('Test SlidingWindowCounter Rate Limiter', () => {
383383

384384
// 9 + 8 * .25 = 11, over capacity (request should be blocked)
385385
const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.75, 9);
386-
expect(result.tokens).toBe(10);
386+
expect(result.tokens).toBe(8);
387387
expect(result.success).toBe(false);
388388

389389
// currentTokens (in current fixed window): 0
@@ -407,7 +407,7 @@ xdescribe('Test SlidingWindowCounter Rate Limiter', () => {
407407

408408
// 11 + 8 * .01 = 11, above capacity (request should be blocked)
409409
const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 11);
410-
expect(result.tokens).toBe(10);
410+
expect(result.tokens).toBe(2);
411411
expect(result.success).toBe(false);
412412

413413
// currentTokens (in current fixed window): 0
@@ -465,7 +465,7 @@ xdescribe('Test SlidingWindowCounter Rate Limiter', () => {
465465
await (
466466
await limiter.processRequest(user1, timestamp + WINDOW_SIZE, 4)
467467
).tokens
468-
).toBe(2);
468+
).toBe(1);
469469
// currentTokens (in current fixed window): 0
470470
// previousTokens (in previous fixed window): 8
471471
const count = await getWindowFromClient(client, user1);

0 commit comments

Comments
 (0)