-
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathrateLimiterSetup.ts
More file actions
165 lines (153 loc) · 6.18 KB
/
Copy pathrateLimiterSetup.ts
File metadata and controls
165 lines (153 loc) · 6.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import EventEmitter from 'events';
import Redis from 'ioredis';
import { RateLimiter, RateLimiterConfig, RateLimiterResponse } from '../@types/rateLimit';
import TokenBucket from '../rateLimiters/tokenBucket';
import SlidingWindowCounter from '../rateLimiters/slidingWindowCounter';
import SlidingWindowLog from '../rateLimiters/slidingWindowLog';
import FixedWindow from '../rateLimiters/fixedWindow';
/**
* Instatieate the rateLimiting algorithm class based on the developer selection and options
*
* @export
* @param {RateLimiterConfig} rateLimiterConfig limiter selection and option
* @param {Redis} client
* @param {number} keyExpiry
* @return {RateLimiter}
*/
export default function setupRateLimiter(
rateLimiterConfig: RateLimiterConfig,
client: Redis,
keyExpiry: number
): RateLimiter {
let rateLimiter: RateLimiter;
/**
* We are using a queue and event emitter to handle situations where a user has two concurrent requests being processed.
* The trailing request will be added to the queue to and await the prior request processing by the rate-limiter
* This will maintain the consistency and accuracy of the cache when under load from one user
*/
// stores request IDs for each user in an array to be processed
const requestQueues: { [index: string]: string[] } = {};
// Manages processing of requests queue
const requestEvents = new EventEmitter();
// processes requests (by resolving promises) that have been throttled by throttledProcess
async function processRequestResolver(
userId: string,
timestamp: number,
tokens: number,
processRequest: (
userId: string,
timestamp: number,
tokens: number
) => Promise<RateLimiterResponse>,
resolve: (value: RateLimiterResponse | PromiseLike<RateLimiterResponse>) => void,
reject: (reason: unknown) => void
) {
try {
const response = await processRequest(userId, timestamp, tokens);
requestQueues[userId] = requestQueues[userId].slice(1);
resolve(response);
// trigger the next event and delete the request queue for this user if there are no more requests to process
requestEvents.emit(requestQueues[userId][0]);
if (requestQueues[userId].length === 0) delete requestQueues[userId];
} catch (err) {
reject(err);
}
}
/**
* Throttle rateLimiter.processRequest based on user IP to prevent inaccurate redis reads
* Throttling is based on a event driven promise fulfillment approach.
* Each time a request is received a promise is added to the user's request queue. The promise "subscribes"
* to the previous request in the user's queue then calls processRequest and resolves once the previous request
* is complete.
* @param userId
* @param timestamp
* @param tokens
* @returns
*/
async function throttledProcess(
processRequest: (
userId: string,
timestamp: number,
tokens: number
) => Promise<RateLimiterResponse>,
userId: string,
timestamp: number,
tokens = 1
): Promise<RateLimiterResponse> {
// Alternatively use crypto.randomUUID() to generate a random uuid
const requestId = `${timestamp}${tokens}`;
if (!requestQueues[userId]) {
requestQueues[userId] = [];
}
requestQueues[userId].push(requestId);
return new Promise((resolve, reject) => {
if (requestQueues[userId].length > 1) {
requestEvents.once(requestId, async () => {
processRequestResolver(
userId,
timestamp,
tokens,
processRequest,
resolve,
reject
);
});
} else {
processRequestResolver(userId, timestamp, tokens, processRequest, resolve, reject);
}
});
}
try {
switch (rateLimiterConfig.type) {
case 'TOKEN_BUCKET':
rateLimiter = new TokenBucket(
rateLimiterConfig.capacity,
rateLimiterConfig.refillRate,
client,
keyExpiry
);
break;
case 'LEAKY_BUCKET':
throw new Error('Leaky Bucket algonithm has not be implemented.');
case 'FIXED_WINDOW':
rateLimiter = new FixedWindow(
rateLimiterConfig.capacity,
rateLimiterConfig.windowSize,
client,
keyExpiry
);
break;
case 'SLIDING_WINDOW_LOG':
rateLimiter = new SlidingWindowLog(
rateLimiterConfig.windowSize,
rateLimiterConfig.capacity,
client,
keyExpiry
);
break;
case 'SLIDING_WINDOW_COUNTER':
rateLimiter = new SlidingWindowCounter(
rateLimiterConfig.windowSize,
rateLimiterConfig.capacity,
client,
keyExpiry
);
break;
default:
// typescript should never let us invoke this function with anything other than the options above
throw new Error('Selected rate limiting algorithm is not suppported');
}
// Overwrite the processRequest method with a throttled implementation to ensure async redis interactions are handled
// sequentially for each user.
const boundProcessRequest = rateLimiter.processRequest.bind(rateLimiter);
rateLimiter.processRequest = async (
userId: string,
timestamp: number,
tokens = 1
): Promise<RateLimiterResponse> =>
throttledProcess(boundProcessRequest, userId, timestamp, tokens);
return rateLimiter;
} catch (err) {
throw new Error(`Error in expressGraphQLRateLimiter setting up rate-limiter: ${err}`);
}
}