Skip to content

Commit d6155ef

Browse files
Yufei WuYufei Wu
authored andcommitted
Merge branch 'dev' of https://github.com/oslabs-beta/GraphQL-Gate into fw/fixedWindow
2 parents 53e0d63 + 0c4801e commit d6155ef

File tree

9 files changed

+934
-50
lines changed

9 files changed

+934
-50
lines changed

src/@types/rateLimit.d.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface RateLimiter {
1616
export interface RateLimiterResponse {
1717
success: boolean;
1818
tokens: number;
19+
retryAfter?: number;
1920
}
2021

2122
export interface RedisBucket {
@@ -32,7 +33,7 @@ export interface RedisWindow {
3233
export interface RedisWindow {
3334
currentTokens: number;
3435
previousTokens: number;
35-
fixedWindowStart?: number;
36+
fixedWindowStart: number;
3637
}
3738

3839
export type RedisLog = RedisBucket[];
@@ -54,18 +55,15 @@ export interface TokenBucketOptions {
5455
}
5556

5657
/**
57-
* @type {number} windowSize - Size of each fixed window and the rolling window
58-
* @type {number} capacity - Number of tokens a window can hold
58+
* @type {number} windowSize - size of the window in milliseconds
59+
* @type {number} capacity - max number of tokens that can be used in the bucket
5960
*/
60-
export interface SlidingWindowCounterOptions {
61+
export interface WindowOptions {
6162
windowSize: number;
6263
capacity: number;
6364
}
6465

6566
// TODO: This will be a union type where we can specify Option types for other Rate Limiters
66-
// Record<string, never> represents the empty object for alogorithms that don't require settings
67+
// Record<string, never> represents the empty object for algorithms that don't require settings
6768
// and might be able to be removed in the future.
68-
export type RateLimiterOptions =
69-
| TokenBucketOptions
70-
| SlidingWindowCounterOptions
71-
| Record<string, never>;
69+
export type RateLimiterOptions = TokenBucketOptions | Record<string, never>;

src/analysis/buildTypeWeights.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,9 @@ function parseObjectFields(
8080
// Iterate through the fields and add the required data to the result
8181
Object.keys(fields).forEach((field: string) => {
8282
// The GraphQL type that this field represents
83-
const fieldType: GraphQLOutputType = fields[field].type;
84-
if (
85-
isScalarType(fieldType) ||
86-
(isNonNullType(fieldType) && isScalarType(fieldType.ofType))
87-
) {
83+
let fieldType: GraphQLOutputType = fields[field].type;
84+
if (isNonNullType(fieldType)) fieldType = fieldType.ofType;
85+
if (isScalarType(fieldType)) {
8886
result.fields[field] = {
8987
weight: typeWeights.scalar,
9088
// resolveTo: fields[field].name.toLowerCase(),
@@ -100,7 +98,8 @@ function parseObjectFields(
10098
};
10199
} else if (isListType(fieldType)) {
102100
// 'listType' is the GraphQL type that the list resolves to
103-
const listType = fieldType.ofType;
101+
let listType = fieldType.ofType;
102+
if (isNonNullType(listType)) listType = listType.ofType;
104103
if (isScalarType(listType) && typeWeights.scalar === 0) {
105104
// list won't compound if weight is zero
106105
result.fields[field] = {
@@ -115,7 +114,6 @@ function parseObjectFields(
115114
fields[field].args.forEach((arg: GraphQLArgument) => {
116115
// If field has an argument matching one of the limiting keywords and resolves to a list
117116
// then the weight of the field should be dependent on both the weight of the resolved type and the limiting argument.
118-
// FIXME: Can nonnull wrap list types?
119117
if (KEYWORDS.includes(arg.name)) {
120118
// Get the type that comprises the list
121119
result.fields[field] = {
@@ -183,6 +181,7 @@ function compareTypes(a: GraphQLOutputType, b: GraphQLOutputType): boolean {
183181
return (
184182
(isObjectType(b) && isObjectType(a) && a.name === b.name) ||
185183
(isUnionType(b) && isUnionType(a) && a.name === b.name) ||
184+
(isEnumType(b) && isEnumType(a) && a.name === b.name) ||
186185
(isInterfaceType(b) && isInterfaceType(a) && a.name === b.name) ||
187186
(isScalarType(b) && isScalarType(a) && a.name === b.name) ||
188187
(isListType(b) && isListType(a) && compareTypes(b.ofType, a.ofType)) ||
@@ -289,24 +288,26 @@ function parseUnionTypes(
289288
* c. objects have a resolveTo type.
290289
* */
291290

292-
const current = commonFields[field].type;
291+
let current = commonFields[field].type;
292+
if (isNonNullType(current)) current = current.ofType;
293293
if (isScalarType(current)) {
294294
fieldTypes[field] = {
295295
weight: commonFields[field].weight,
296296
};
297-
} else if (isObjectType(current) || isInterfaceType(current) || isUnionType(current)) {
297+
} else if (
298+
isObjectType(current) ||
299+
isInterfaceType(current) ||
300+
isUnionType(current) ||
301+
isEnumType(current)
302+
) {
298303
fieldTypes[field] = {
299304
resolveTo: commonFields[field].resolveTo,
300-
weight: typeWeights.object,
301305
};
302306
} else if (isListType(current)) {
303307
fieldTypes[field] = {
304308
resolveTo: commonFields[field].resolveTo,
305309
weight: commonFields[field].weight,
306310
};
307-
} else if (isNonNullType(current)) {
308-
throw new Error('non null types not supported on unions');
309-
// TODO: also a recursive data structure
310311
} else {
311312
throw new Error('Unhandled union type. Should never get here');
312313
}

src/middleware/rateLimiterSetup.ts

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

65
/**
@@ -26,13 +25,12 @@ export default function setupRateLimiter(
2625
break;
2726
case 'LEAKY_BUCKET':
2827
throw new Error('Leaky Bucket algonithm has not be implemented.');
29-
break;
3028
case 'FIXED_WINDOW':
3129
throw new Error('Fixed Window algonithm has not be implemented.');
32-
break;
3330
case 'SLIDING_WINDOW_LOG':
34-
throw new Error('Sliding Window Log has not be implemented.');
35-
break;
31+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
32+
// @ts-ignore
33+
return new SlidingWindowLog(options.windowSize, options.capacity, client);
3634
case 'SLIDING_WINDOW_COUNTER':
3735
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
3836
// @ts-ignore
@@ -41,6 +39,5 @@ export default function setupRateLimiter(
4139
default:
4240
// typescript should never let us invoke this function with anything other than the options above
4341
throw new Error('Selected rate limiting algorithm is not suppported');
44-
break;
4542
}
4643
}

src/rateLimiters/slidingWindowLog.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Redis from 'ioredis';
2-
import { RateLimiter, RateLimiterResponse } from '../@types/rateLimit';
2+
import { RateLimiter, RateLimiterResponse, RedisBucket, RedisLog } from '../@types/rateLimit';
33

44
/**
55
* The SlidingWindowLog instance of a RateLimiter limits requests based on a unique user ID.
@@ -13,7 +13,7 @@ import { RateLimiter, RateLimiterResponse } from '../@types/rateLimit';
1313
* 2. Any requests that are older than window size are dropped from the log.
1414
* 3. The complexity of the current request is added to the complexity of all requests in the log.
1515
* 4. If the request exceeds the specified capacity it is dropped.
16-
* 5. Otherwise the request is allowed and ther current request is added to the log.
16+
* 5. Otherwise the request is allowed and the current request is added to the end of the log (if it has a complexity > 0).
1717
*/
1818
class SlidingWindowLog implements RateLimiter {
1919
private windowSize: number;
@@ -33,7 +33,40 @@ class SlidingWindowLog implements RateLimiter {
3333
this.capacity = capacity;
3434
this.client = client;
3535
if (windowSize <= 0 || capacity <= 0)
36-
throw SyntaxError('SlidingWindowLog windowSize and capacity must be positive');
36+
throw SyntaxError('SlidingWindowLog window size and capacity must be positive');
37+
38+
// TODO: Define lua script for server side computation using either sorted sets or lists
39+
// while x.timestamp + window_size < timestamp lpop
40+
// //https://stackoverflow.com/questions/35677682/filtering-deleting-items-from-a-redis-set
41+
// this.client.defineCommand('popWindow', {
42+
// // 2 value timestamp and complexity of this request
43+
// lua: `
44+
// local totalComplexity = 0 -- complexity of active requests
45+
// local expiredMembers = 0 -- number of requests to remove
46+
// local key = keys[1] -- uuid
47+
// local current_time = keys[2]
48+
49+
// for index, value in next, redis.call(key, ????) do
50+
// -- string comparisson of timestamps
51+
// if .... then
52+
53+
// else
54+
// totalComplexity += ????
55+
// end
56+
// end
57+
58+
// redis.call(pop, ???)
59+
60+
// if total_complexity < window_size then
61+
// then
62+
// end
63+
// return {
64+
65+
// }
66+
// `,
67+
// numberOfKeys: 3, // uuid
68+
// readOnly: true,
69+
// });
3770
}
3871

3972
/**
@@ -50,9 +83,63 @@ class SlidingWindowLog implements RateLimiter {
5083
): Promise<RateLimiterResponse> {
5184
// set the expiry of key-value pairs in the cache to 24 hours
5285
const keyExpiry = 86400000; // TODO: Make this a global for consistency across each algo.
53-
if (tokens > this.capacity) return { success: false, tokens: this.capacity };
5486

55-
throw new Error('SlidingWindowLog.processRequest not implemented');
87+
// Each user's log is represented by a redis list with a score = request timestamp
88+
// and a value equal to the complexity
89+
// Drop expired requests from the log. represented by a sorted set in redis
90+
91+
// Get the log from redis
92+
let requestLog: RedisLog = JSON.parse((await this.client.get(uuid)) || '[]');
93+
94+
// Iterate through the list in reverse and count active tokens
95+
// This allows us to track the threshold for when this request would be allowed if it is blocked
96+
// Stop at the first timestamp that's expired and cut the rest.
97+
98+
const cutoff = timestamp - this.windowSize;
99+
let tokensInLog = 0; // total active tokens in the log
100+
let cutoffIndex = 0; // index of oldest active request
101+
let lastAllowedIndex = requestLog.length; // Index of oldest request in the log for which this request would be allowed.
102+
103+
for (let index = requestLog.length - 1; index >= 0; index--) {
104+
if (cutoff >= requestLog[index].timestamp) {
105+
// we reached the first expired request
106+
cutoffIndex = index + 1;
107+
break;
108+
} else {
109+
// the request is active
110+
tokensInLog += requestLog[index].tokens;
111+
if (this.capacity - tokensInLog >= tokens) {
112+
// the log is able to accept the current request
113+
lastAllowedIndex = index;
114+
}
115+
}
116+
}
117+
118+
// Time (ms) after which the current request would succeed if it is blocked.
119+
let retryAfter: number;
120+
121+
// Request will never be allowed
122+
if (tokens > this.capacity) retryAfter = Infinity;
123+
// need the request before lastAllowedIndex
124+
else if (lastAllowedIndex > 0)
125+
retryAfter = this.windowSize + requestLog[lastAllowedIndex - 1].timestamp;
126+
else retryAfter = 0; // request is allowed
127+
128+
// Conditional check to avoid unecessary slice
129+
if (cutoffIndex > 0) requestLog = requestLog.slice(cutoffIndex);
130+
131+
// allow/disallow current request
132+
if (tokensInLog + tokens <= this.capacity) {
133+
// update the log
134+
if (tokens > 0) requestLog.push({ timestamp, tokens });
135+
await this.client.setex(uuid, keyExpiry, JSON.stringify(requestLog));
136+
tokensInLog += tokens;
137+
return { success: true, tokens: this.capacity - tokensInLog };
138+
}
139+
140+
await this.client.setex(uuid, keyExpiry, JSON.stringify(requestLog));
141+
142+
return { success: false, tokens: this.capacity - tokensInLog, retryAfter };
56143
}
57144

58145
/**

0 commit comments

Comments
 (0)