Skip to content

Commit b4a4698

Browse files
Yufei WuYufei Wu
authored andcommitted
working on test
1 parent 4349a67 commit b4a4698

File tree

3 files changed

+172
-0
lines changed

3 files changed

+172
-0
lines changed

src/@types/rateLimit.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export interface RedisBucket {
2222
tokens: number;
2323
timestamp: number;
2424
}
25+
export interface RedisWindow {
26+
currentTokens: number;
27+
// null if limiter is currently on the initial fixed window
28+
previousTokens?: number | null;
29+
fixedWindowStart: number;
30+
}
2531

2632
export type RateLimiterSelection =
2733
| 'TOKEN_BUCKET'

src/rateLimiters/fixedWindow.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import Redis from 'ioredis';
2+
import { RateLimiter, RateLimiterResponse, RedisWindow } from '../@types/rateLimit';
3+
4+
/**
5+
* The FixedWindow instance of a RateLimiter limits requests based on a unique user ID and a fixed time window.
6+
* Whenever a user makes a request the following steps are performed:
7+
* 1. Define the time window with fixed amount of queries.
8+
* 2. Update the timestamp of the last request.
9+
* 3. Allow the request and decrease the allowed amount of requests if the user has enough at this time window.
10+
* 4. Otherwise, disallow the request until the next time window opens.
11+
*/
12+
13+
class FixedWindow implements RateLimiter {
14+
private capacity: number;
15+
16+
private windowSize: number;
17+
18+
private client: Redis;
19+
20+
/**
21+
* Create a new instance of a FixedWindow rate limiter that can be connected to any database store
22+
* @param capacity max requests capacity in one time window
23+
* @param windowSize rate at which the token bucket is refilled
24+
* @param client redis client where rate limiter will cache information
25+
*/
26+
27+
constructor(capacity: number, windowSize: number, client: Redis) {
28+
this.capacity = capacity;
29+
this.windowSize = windowSize;
30+
this.client = client;
31+
if (windowSize <= 0 || capacity <= 0)
32+
throw Error('FixedWindow windowSize and capacity must be positive');
33+
}
34+
35+
/**
36+
* Fixed Window
37+
* _________________________________
38+
* | *full capacity |
39+
* | | move to next time window
40+
* | token adds up until full | ---------->
41+
*____._________________________________.____
42+
* |<-- window size -->|
43+
*current timestamp next timestamp
44+
*
45+
*
46+
* @param {string} uuid - unique identifer used to throttle requests
47+
* @param {number} timestamp - time the request was recieved
48+
* @param {number} [tokens=1] - complexity of the query for throttling requests
49+
* @return {*} {Promise<RateLimiterResponse>}
50+
* @memberof FixedWindow
51+
*/
52+
async processRequest(
53+
uuid: string,
54+
timestamp: number,
55+
tokens = 1
56+
): Promise<RateLimiterResponse> {
57+
// set the expiry of key-value pairs in the cache to 24 hours
58+
const keyExpiry = 86400000;
59+
60+
// attempt to get the value for the uuid from the redis cache
61+
const windowJSON = await this.client.get(uuid);
62+
63+
if (windowJSON === null) {
64+
const newUserWindow: RedisWindow = {
65+
currentTokens: tokens <= this.capacity ? tokens : 0,
66+
fixedWindowStart: timestamp,
67+
};
68+
69+
if (tokens <= this.capacity) {
70+
await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow));
71+
return { success: true, tokens: this.capacity - newUserWindow.currentTokens };
72+
}
73+
74+
await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow));
75+
}
76+
77+
const window: RedisWindow = await JSON.parse(windowJSON as string);
78+
79+
const updatedUserWindow = this.updateTimeWindow(window, timestamp);
80+
if (window.currentTokens > this.capacity) {
81+
await this.client.setex(uuid, keyExpiry, JSON.stringify(updatedUserWindow));
82+
return { success: false, tokens: window.currentTokens };
83+
}
84+
await this.client.setex(uuid, keyExpiry, JSON.stringify(updatedUserWindow));
85+
86+
return { success: false, tokens: updatedUserWindow.currentTokens };
87+
}
88+
89+
/**
90+
* Resets the rate limiter to the intial state by clearing the redis store.
91+
*/
92+
public reset(): void {
93+
this.client.flushall();
94+
}
95+
96+
private updateTimeWindow = (window: RedisWindow, timestamp: number): RedisWindow => {
97+
const updatedUserWindow: RedisWindow = {
98+
currentTokens: window.currentTokens,
99+
fixedWindowStart: window.fixedWindowStart,
100+
};
101+
if (timestamp > window.fixedWindowStart + this.windowSize) {
102+
updatedUserWindow.fixedWindowStart = window.fixedWindowStart + this.windowSize;
103+
updatedUserWindow.currentTokens = 0;
104+
}
105+
return updatedUserWindow;
106+
};
107+
}
108+
109+
export default FixedWindow;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as ioredis from 'ioredis';
2+
import { RedisWindow } from '../../src/@types/rateLimit';
3+
import FixedWindow from '../../src/rateLimiters/fixedWindow';
4+
5+
// eslint-disable-next-line @typescript-eslint/no-var-requires
6+
const RedisMock = require('ioredis-mock');
7+
8+
const CAPACITY = 10;
9+
const WINDOW_SIZE = 6000;
10+
11+
let limiter: FixedWindow;
12+
let client: ioredis.Redis;
13+
let timestamp: number;
14+
const user1 = '1';
15+
const user2 = '2';
16+
const user3 = '3';
17+
const user4 = '4';
18+
19+
async function getWindowFromClient(redisClient: ioredis.Redis, uuid: string): Promise<RedisWindow> {
20+
const res = await redisClient.get(uuid);
21+
// if no uuid is found, return -1 for tokens and timestamp, which are both impossible
22+
if (res === null) return { currentTokens: -1, fixedWindowStart: -1 };
23+
return JSON.parse(res);
24+
}
25+
26+
async function setTokenCountInClient(
27+
redisClient: ioredis.Redis,
28+
uuid: string,
29+
tokens: number,
30+
time: number
31+
) {
32+
const value: RedisWindow = { currentTokens: tokens, fixedWindowStart: time };
33+
await redisClient.set(uuid, JSON.stringify(value));
34+
}
35+
describe('Test FixedWindow Rate Limiter', () => {
36+
beforeEach(async () => {
37+
client = new RedisMock();
38+
limiter = new FixedWindow(CAPACITY, WINDOW_SIZE, client);
39+
timestamp = new Date().valueOf();
40+
});
41+
describe('FixedWindow returns correct number of tokens and updates redis store as expected', () => {
42+
describe('after an ALLOWED request...', () => {
43+
afterEach(() => {
44+
client.flushall();
45+
});
46+
test('current time window has no token initially', async () => {
47+
// zero token used in this time window
48+
const withdraw5 = 5;
49+
expect((await limiter.processRequest(user1, timestamp, withdraw5)).tokens).toBe(
50+
CAPACITY - withdraw5
51+
);
52+
const tokenCountFull = await getWindowFromClient(client, user1);
53+
expect(tokenCountFull.currentTokens).toBe(CAPACITY - withdraw5);
54+
});
55+
});
56+
});
57+
});

0 commit comments

Comments
 (0)