From 46b6d1a9b455d8d1df46f28527c13b1f18cabffc Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Sat, 9 Jul 2022 11:03:10 -0700 Subject: [PATCH 01/16] spec updates --- src/rateLimiters/slidingWindowCounter.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index 03c867e..54696d3 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -9,12 +9,12 @@ import { RateLimiter, RateLimiterResponse, RedisWindow } from '../@types/rateLim * takeup in each. * * Whenever a user makes a request the following steps are performed: - * 1. Fixed minute windows are defined along with redis caches if previously undefined. - * 2. Rolling minute windows are defined or updated based on the timestamp of the new request. + * 1. Fixed windows are defined along with redis caches if previously undefined. + * 2. Rolling windows are defined or updated based on the timestamp of the new request. * 3. Counter of the current fixed window is updated with the new request's token usage. * 4. If a new minute interval is reached, the averaging formula is run to prevent fixed window's flaw * of flooded requests around window borders - * (ex. 10 token capacity: 1m59s 10 reqs 2m2s 10 reqs) + * (ex. 1m windows, 10 token capacity: 1m59s 10 reqs 2m2s 10 reqs) */ class SlidingWindowCounter implements RateLimiter { private windowSize: number; @@ -24,7 +24,7 @@ class SlidingWindowCounter implements RateLimiter { private client: Redis; /** - * Create a new instance of a TokenBucket rate limiter that can be connected to any database store + * Create a new instance of a SlidingWindowCounter rate limiter that can be connected to any database store * @param windowSize size of each window in milliseconds (fixed and rolling) * @param capacity max capacity of tokens allowed per fixed window * @param client redis client where rate limiter will cache information From 76873908e3343a51d36e595837f380b59fe5508a Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Thu, 14 Jul 2022 23:01:13 -0700 Subject: [PATCH 02/16] merge conflicts --- src/@types/rateLimit.d.ts | 3 +- src/rateLimiters/slidingWindowCounter.ts | 124 ++++- .../rateLimiters/slidingWindowCounter.test.ts | 476 ++++++++++++++++++ 3 files changed, 577 insertions(+), 26 deletions(-) create mode 100644 test/rateLimiters/slidingWindowCounter.test.ts diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 5b1b975..74331f4 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -25,7 +25,8 @@ export interface RedisBucket { export interface RedisWindow { currentTokens: number; - previousTokens: number; + // null if limiter is currently on the initial fixed window + previousTokens: number | null; fixedWindowStart: number; } diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index 54696d3..159cef5 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -38,12 +38,37 @@ class SlidingWindowCounter implements RateLimiter { } /** + * @function processRequest - Sliding window counter algorithm to allow or block + * based on the depth/complexity (in amount of tokens) of incoming requests. * + * First, checks if a window exists in the redis cache. + * + * If not, then `fixedWindowStart` is set as the current timestamp, and `currentTokens` + * is checked against `capacity`. If enough room exists for the request, returns + * success as true and tokens as how many tokens remain in the current fixed window. + * + * If a window does exist in the cache, we first check if the timestamp is greater than + * the fixedWindowStart + windowSize. + * + * If it isn't then we check the number of tokens in the arguments as well as in the cache + * against the capacity and return success or failure from there while updating the cache. + * + * If the timestamp is over the windowSize beyond the fixedWindowStart, then we update fixedWindowStart + * to be fixedWindowStart + windowSize (to create a new fixed window) and + * make previousTokens = currentTokens, and currentTokens equal to the number of tokens in args, if + * not over capacity. + * + * Once previousTokens is not null, we then run functionality using the rolling window to compute + * the formula this entire limiting algorithm is distinguished by: + * + * currentTokens + previousTokens * overlap % of rolling window over previous fixed window * * @param {string} uuid - unique identifer used to throttle requests * @param {number} timestamp - time the request was recieved * @param {number} [tokens=1] - complexity of the query for throttling requests * @return {*} {Promise} + * RateLimiterResponse: {success: boolean, tokens: number} + * (tokens represents the remaining available capacity of the window) * @memberof SlidingWindowCounter */ async processRequest( @@ -57,31 +82,80 @@ class SlidingWindowCounter implements RateLimiter { // attempt to get the value for the uuid from the redis cache const windowJSON = await this.client.get(uuid); - // // if the response is null, we need to create a window for the user - // if (windowJSON === null) { - // // rolling window is 1 minute long - // const rollingWindowEnd = timestamp + 60000; - - // // grabs the actual minute from the timestamp to create fixed window - // const fixedWindowStart = timestamp - (timestamp % 10000); - // const fixedWindowEnd = fixedWindowStart + 60000; - - // const newUserWindow: RedisWindow = { - // // conditionally set tokens depending on how many are requested compared to the capacity - // tokens: tokens > this.capacity ? this.capacity : this.capacity - tokens, - // timestamp, - // }; - - // // reject the request, not enough tokens could even be in the bucket - // if (tokens > this.capacity) { - // await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow)); - // return { success: false, tokens: this.capacity }; - // } - // await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow)); - // return { success: true, tokens: newUserWindow.tokens }; - // } - - return { success: true, tokens: 0 }; + // if the response is null, we need to create a window for the user + if (windowJSON === null) { + const newUserWindow: RedisWindow = { + // current and previous tokens represent how many tokens are in each window + currentTokens: tokens <= this.capacity ? tokens : 0, + previousTokens: null, + fixedWindowStart: timestamp, + }; + + if (tokens > this.capacity) { + await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow)); + // tokens property represents how much capacity remains + return { success: false, tokens: this.capacity }; + } + + await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow)); + return { success: true, tokens: this.capacity - newUserWindow.currentTokens }; + } + + // if the cache is populated + + const window: RedisWindow = await JSON.parse(windowJSON); + + let updatedUserWindow: RedisWindow = { + currentTokens: window.currentTokens, + previousTokens: window.previousTokens, + fixedWindowStart: window.fixedWindowStart, + }; + + // if request time is in a new window + if (timestamp > window.fixedWindowStart + this.windowSize + 1) { + updatedUserWindow.previousTokens = updatedUserWindow.currentTokens; + updatedUserWindow.currentTokens = 0; + updatedUserWindow.fixedWindowStart = window.fixedWindowStart + this.windowSize; + } + + // assigned to avoid TS error, this var will never be used as 0 + // var is declared here so that below can be inside a conditional for efficiency's sake + let rollingWindowProportion: number = 0; + + if (updatedUserWindow.previousTokens) { + // subtract window size by current time less fixed window's start + // current time less fixed window start is the amount that rolling window is in current window + // to get amount that rolling window is in previous window, we subtract this difference by window size + // then we divide this amount by window size to get the proportion of rolling window in previous window + // ex. 60000 - (1million+5 - 1million) = 59995 / 60000 = 0.9999 + rollingWindowProportion = + (this.windowSize - (timestamp - updatedUserWindow.fixedWindowStart)) / + this.windowSize; + + // remove unecessary decimals, 0.xx is enough + rollingWindowProportion = rollingWindowProportion - (rollingWindowProportion % 0.01); + } + + // the sliding window counter formula + // ex. tokens(1) + currentTokens(2) + previousTokens(4) * RWP(.75) = 6 < capacity(10) + // adjusts formula if previousTokens is null + const rollingWindowAllowal = updatedUserWindow.previousTokens + ? tokens + + updatedUserWindow.currentTokens + + updatedUserWindow.previousTokens * rollingWindowProportion <= + this.capacity + : tokens + updatedUserWindow.currentTokens <= this.capacity; + + // if request is allowed + if (rollingWindowAllowal) { + updatedUserWindow.currentTokens += tokens; + await this.client.setex(uuid, keyExpiry, JSON.stringify(updatedUserWindow)); + return { success: true, tokens: this.capacity - updatedUserWindow.currentTokens }; + } + + // if request is blocked + await this.client.setex(uuid, keyExpiry, JSON.stringify(updatedUserWindow)); + return { success: false, tokens: this.capacity - updatedUserWindow.currentTokens }; } /** diff --git a/test/rateLimiters/slidingWindowCounter.test.ts b/test/rateLimiters/slidingWindowCounter.test.ts new file mode 100644 index 0000000..5b2bf05 --- /dev/null +++ b/test/rateLimiters/slidingWindowCounter.test.ts @@ -0,0 +1,476 @@ +import * as ioredis from 'ioredis'; +import { RedisWindow } from '../../src/@types/rateLimit'; +import SlidingWindowCounter from '../../src/rateLimiters/slidingWindowCounter'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const RedisMock = require('ioredis-mock'); + +const CAPACITY = 10; // allowed tokens per fixed window +const WINDOW_SIZE = 60000; // size of window in ms (this is 1 minute) + +let limiter: SlidingWindowCounter; +let client: ioredis.Redis; +let timestamp: number; +const user1 = '1'; +const user2 = '2'; +const user3 = '3'; +const user4 = '4'; + +async function getWindowFromClient(redisClient: ioredis.Redis, uuid: string): Promise { + const res = await redisClient.get(uuid); + // if no uuid is found, return -1 for all values, which is impossible + if (res === null) return { currentTokens: -1, previousTokens: -1, fixedWindowStart: -1 }; + return JSON.parse(res); +} + +// helper function to set mock redis cache +async function setTokenCountInClient( + redisClient: ioredis.Redis, + uuid: string, + currentTokens: number, + previousTokens: number | null, + fixedWindowStart: number +) { + const value: RedisWindow = { currentTokens, previousTokens, fixedWindowStart }; + await redisClient.set(uuid, JSON.stringify(value)); +} + +describe('Test TokenBucket Rate Limiter', () => { + beforeEach(async () => { + // init a mock redis cache + client = new RedisMock(); + // init a new sliding window counter instance + limiter = new SlidingWindowCounter(WINDOW_SIZE, CAPACITY, client); + // get the current time + timestamp = new Date().valueOf(); + }); + + describe('SlidingWindowCounter returns correct number of tokens and updates redis store as expected', () => { + describe('after an ALLOWED request...', () => { + afterEach(() => { + client.flushall(); + }); + test('fixed window is initially empty', async () => { + // window is intially empty + const withdraw5 = 5; + expect((await limiter.processRequest(user1, timestamp, withdraw5)).tokens).toBe( + CAPACITY - withdraw5 + ); + const tokenCountFull = await getWindowFromClient(client, user1); + expect(tokenCountFull.currentTokens).toBe(CAPACITY - withdraw5); + }); + + test('fixed window is partially full and request has leftover tokens', async () => { + // Window is partially full but still has space for another small request + const initial = 6; + const partialWithdraw = 3; + expect((await limiter.processRequest(user2, timestamp, initial)).tokens).toBe( + CAPACITY - initial + ); + expect( + (await limiter.processRequest(user2, timestamp, partialWithdraw)).tokens + ).toBe(CAPACITY - (initial + partialWithdraw)); + + const tokenCountPartial = await getWindowFromClient(client, user2); + expect(tokenCountPartial.currentTokens).toBe(initial + partialWithdraw); + }); + + // window partially full and no leftover tokens after request + test('fixed window is partially full and request has no leftover tokens', async () => { + const initial = 6; + await setTokenCountInClient(client, user2, initial, null, timestamp); + expect( + (await limiter.processRequest(user2, timestamp, CAPACITY - initial)).tokens + ).toBe(0); + const tokenCountPartialToEmpty = await getWindowFromClient(client, user2); + expect(tokenCountPartialToEmpty.currentTokens).toBe(10); + }); + + // Window initially full but enough time elapsed to paritally fill window since last request + test('fixed window is initially full but after new fixed window is initialized request is allowed', async () => { + await setTokenCountInClient(client, user4, 10, null, timestamp); + // tokens returned in processRequest is equal to the capacity + // still available in the fixed window + expect( + (await limiter.processRequest(user4, timestamp + WINDOW_SIZE + 1, 1)).tokens + ).toBe(0); // here, we expect the rolling window to only allow 1 token, b/c + // only 1ms has passed since the previous fixed window + + // `currentTokens` cached is the amount of tokens + // currently in the fixed window. + // this differs from token bucket, which caches the amount + // of tokens still available for use + const count = await getWindowFromClient(client, user4); + expect(count.currentTokens).toBe(1); + }); + + // three different tests within, with different rolling window proportions (.25, .5, .75) + test('rolling window at 75% allows requests under capacity', async () => { + // 75% of rolling window present in previous fixed window + // 1.25*60000 = 75000 (time after initial fixedWindowStart + // to set rolling window at 75% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, null, timestamp); + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 8); + + // 4 + 8 * .75 = 10, right at capacity (request should be allowed) + // tokens until capacity: 0 (tokens property returned by processRequest method) + expect( + (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.25, 4)).tokens + ).toBe(0); + + // currentTokens (in current fixed window): 4 + // previousTokens (in previous fixed window): 8 + const count1 = await getWindowFromClient(client, user4); + expect(count1.currentTokens).toBe(4); + expect(count1.previousTokens).toBe(8); + }); + + test('rolling window at 50% allows requests under capacity', async () => { + // 50% of rolling window present in previous fixed window + // 1.5*60000 = 90000 (time after initial fixedWindowStart + // to set rolling window at 50% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, null, timestamp); + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 8); + + // 4 + 8 * .5 = 8, under capacity (request should be allowed) + // tokens until capacity: 2 (tokens property returned by processRequest method) + expect( + (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.5, 4)).tokens + ).toBe(2); + + // currentTokens (in current fixed window): 4 + // previousTokens (in previous fixed window): 8 + const count = await getWindowFromClient(client, user4); + expect(count.currentTokens).toBe(4); + expect(count.previousTokens).toBe(8); + }); + + test('rolling window at 25% allows requests under capacity', async () => { + // 25% of rolling window present in previous fixed window + // 1.75*60000 = 105000 (time after initial fixedWindowStart + // to set rolling window at 25% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, null, timestamp); + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 8); + + // 4 + 8 * .25 = 6, under capacity (request should be allowed) + // tokens until capacity: 4 (tokens property returned by processRequest method) + expect( + (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.75, 4)).tokens + ).toBe(4); + + // currentTokens (in current fixed window): 4 + // previousTokens (in previous fixed window): 8 + const count = await getWindowFromClient(client, user4); + expect(count.currentTokens).toBe(4); + expect(count.previousTokens).toBe(8); + }); + }); + + describe('after a BLOCKED request...', () => { + afterEach(() => { + client.flushall(); + }); + + test('initial request is greater than capacity', async () => { + // expect remaining tokens to be 10, b/c the 11 token request should be blocked + expect((await limiter.processRequest(user1, timestamp, 11)).tokens).toBe(10); + // expect current tokens in the window to still be 0 + expect((await getWindowFromClient(client, user1)).currentTokens).toBe(0); + }); + + test('window is partially full but not enough time elapsed to reach new window', async () => { + const initRequest = 6; + + await setTokenCountInClient(client, user2, initRequest, null, timestamp); + // expect remaining tokens to be 4, b/c the 5 token request should be blocked + expect( + (await limiter.processRequest(user2, timestamp + WINDOW_SIZE, 5)).tokens + ).toBe(CAPACITY - initRequest); + + // expect current tokens in the window to still be 0 + expect((await getWindowFromClient(client, user2)).currentTokens).toBe(0); + }); + + // 3 rolling window tests with different proportions (.25, .5, .75) + test('rolling window at 75% blocks requests over allowed limit set by formula', async () => { + // 75% of rolling window present in previous fixed window + // 1.25*60000 = 75000 (time after initial fixedWindowStart + // to set rolling window at 75% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, null, timestamp); + + const initRequest = 8; + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE, initRequest); + + // 5 + 8 * .75 = 11, above capacity (request should be blocked) + expect( + (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.25, 5)).tokens + ).toBe(10); + + // currentTokens (in current fixed window): 0 + // previousTokens (in previous fixed window): 8 + const count1 = await getWindowFromClient(client, user4); + expect(count1.currentTokens).toBe(0); + expect(count1.previousTokens).toBe(initRequest); + }); + }); + + test('rolling window at 50% blocks requests over allowed limit set by formula', async () => { + // 50% of rolling window present in previous fixed window + // 1.5*60000 = 90000 (time after initial fixedWindowStart + // to set rolling window at 50% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, null, timestamp); + + const initRequest = 8; + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE, initRequest); + + // 7 + 8 * .5 = 11, over capacity (request should be blocked) + expect( + (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.5, 7)).tokens + ).toBe(10); + + // currentTokens (in current fixed window): 0 + // previousTokens (in previous fixed window): 8 + const count = await getWindowFromClient(client, user4); + expect(count.currentTokens).toBe(0); + expect(count.previousTokens).toBe(initRequest); + }); + + test('rolling window at 25% blocks requests over allowed limit set by formula', async () => { + // 25% of rolling window present in previous fixed window + // 1.75*60000 = 105000 (time after initial fixedWindowStart + // to set rolling window at 25% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, null, timestamp); + + const initRequest = 8; + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE, initRequest); + + // 9 + 8 * .25 = 11, over capacity (request should be blocked) + expect( + (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.75, 9)).tokens + ).toBe(10); + + // currentTokens (in current fixed window): 0 + // previousTokens (in previous fixed window): 8 + const count = await getWindowFromClient(client, user4); + expect(count.currentTokens).toBe(4); + expect(count.previousTokens).toBe(initRequest); + }); + }); + + describe('SlidingWindowCounter functions as expected', () => { + afterEach(() => { + client.flushall(); + }); + + test('allows user to consume current allotment of tokens', async () => { + // "free requests" + expect((await limiter.processRequest(user1, timestamp, 0)).success).toBe(true); + // Test 1 token requested + expect((await limiter.processRequest(user1, timestamp, 1)).success).toBe(true); + // Test < CAPACITY tokens requested + expect((await limiter.processRequest(user2, timestamp, CAPACITY - 1)).success).toBe( + true + ); + // <= CAPACITY tokens requested + expect((await limiter.processRequest(user3, timestamp, CAPACITY)).success).toBe(true); + }); + + test('blocks exceeding requests over token allotment', async () => { + // Test > capacity tokens requested + expect((await limiter.processRequest(user1, timestamp, CAPACITY + 1)).success).toBe( + false + ); + + // Fill up user 1's window + const value: RedisWindow = { + currentTokens: 10, + previousTokens: null, + fixedWindowStart: timestamp, + }; + await client.set(user1, JSON.stringify(value)); + + // window is full. Shouldn't be allowed to take 1 token + expect((await limiter.processRequest(user1, timestamp, 1)).success).toBe(false); + + // Should still be allowed to process "free" requests + expect((await limiter.processRequest(user1, timestamp, 0)).success).toBe(true); + }); + + test('fixed window and current/previous tokens update as expected', async () => { + await setTokenCountInClient(client, user1, 0, null, timestamp); + // fills first window with 4 tokens + await limiter.processRequest(user1, timestamp, 5); + // fills second window with 5 tokens + expect( + await ( + await limiter.processRequest(user1, timestamp + WINDOW_SIZE + 1, 4) + ).tokens + ).toBe(6); + // currentTokens (in current fixed window): 0 + // previousTokens (in previous fixed window): 8 + const count = await getWindowFromClient(client, user4); + // ensures that fixed window is updated when a request goes over + expect(count.fixedWindowStart).toBe(timestamp + WINDOW_SIZE); + // ensures that previous tokens property updates on fixed window change + expect(count.previousTokens).toBe(5); + // ensures that current tokens only represents tokens from current window requests + expect(count.currentTokens).toBe(4); + }); + + test('sliding window allows custom window sizes', async () => { + const newWindowSize = 10000; + + const newLimiter = new SlidingWindowCounter(newWindowSize, CAPACITY, client); + + await newLimiter.processRequest(user1, timestamp, 8); + + // expect that a new window is entered, leaving 9 tokens available after a 1 token request + expect( + (await newLimiter.processRequest(user1, timestamp + newWindowSize + 1, 1)).tokens + ).toBe(9); + }); + + test('sliding window allows custom capacities', async () => { + const newCapacity = 5; + + const newLimiter = new SlidingWindowCounter(WINDOW_SIZE, newCapacity, client); + + // expect that tokens available after request will be consistent with the new capacity + expect((await newLimiter.processRequest(user1, timestamp, newCapacity)).tokens).toBe(0); + }); + + test('users have their own windows', async () => { + const requested = 6; + const user3Tokens = 8; + // Add tokens for user 3 so we have both a user that exists in the store (3) and one that doesn't (2) + await setTokenCountInClient(client, user3, user3Tokens, null, timestamp); + + // issue a request for user 1; + await limiter.processRequest(user1, timestamp, requested); + + // Check that each user has the expected amount of tokens. + expect((await getWindowFromClient(client, user1)).currentTokens).toBe(requested); + // not in the store so this returns -1 + expect((await getWindowFromClient(client, user2)).currentTokens).toBe(-1); + expect((await getWindowFromClient(client, user3)).currentTokens).toBe(user3Tokens); + + await limiter.processRequest(user2, timestamp, 1); + expect((await getWindowFromClient(client, user1)).currentTokens).toBe(requested); + expect((await getWindowFromClient(client, user2)).currentTokens).toBe(1); + expect((await getWindowFromClient(client, user3)).currentTokens).toBe(user3Tokens); + }); + + test("sliding window doesn't allow capacity/window size <= 0", () => { + expect(() => new SlidingWindowCounter(0, 10, client)).toThrow( + 'SlidingWindowCounter windowSize and capacity must be positive' + ); + expect(() => new SlidingWindowCounter(-1, 10, client)).toThrow( + 'SlidingWindowCounter windowSize and capacity must be positive' + ); + expect(() => new SlidingWindowCounter(10, -1, client)).toThrow( + 'SlidingWindowCounter windowSize and capacity must be positive' + ); + expect(() => new SlidingWindowCounter(10, 0, client)).toThrow( + 'SlidingWindowCounter windowSize and capacity must be positive' + ); + }); + + test('all windows should be able to be reset', async () => { + const tokens = 5; + await setTokenCountInClient(client, user1, tokens, null, timestamp); + await setTokenCountInClient(client, user2, tokens, null, timestamp); + await setTokenCountInClient(client, user3, tokens, null, timestamp); + + limiter.reset(); + + expect((await limiter.processRequest(user1, timestamp, CAPACITY)).success).toBe(true); + expect((await limiter.processRequest(user2, timestamp, CAPACITY - 1)).success).toBe( + true + ); + expect((await limiter.processRequest(user3, timestamp, CAPACITY + 1)).success).toBe( + false + ); + }); + }); + + describe('SlidingWindowCounter correctly updates Redis cache', () => { + afterEach(() => { + client.flushall(); + }); + + test('timestamp correctly updated in redis', async () => { + let redisData: RedisWindow; + + // blocked request + await limiter.processRequest(user1, timestamp, CAPACITY + 1); + redisData = await getWindowFromClient(client, user1); + expect(redisData.fixedWindowStart).toBe(timestamp); + + timestamp += 1000; + // allowed request + await limiter.processRequest(user2, timestamp, CAPACITY); + redisData = await getWindowFromClient(client, user2); + expect(redisData.fixedWindowStart).toBe(timestamp); + }); + + test('current/previous tokens correctly updated in redis', async () => { + let redisData: RedisWindow; + + await limiter.processRequest(user1, timestamp, 2); + + redisData = await getWindowFromClient(client, user1); + + expect(redisData.currentTokens).toBe(2); + + await limiter.processRequest(user1, timestamp + WINDOW_SIZE + 1, 3); + + redisData = await getWindowFromClient(client, user1); + + expect(redisData.currentTokens).toBe(3); + expect(redisData.previousTokens).toBe(2); + }); + + test('all windows should be able to be reset', async () => { + // add data to redis + const time = new Date(); + const value = JSON.stringify({ tokens: 0, timestamp: time.valueOf() }); + + await client.set(user1, value); + await client.set(user2, value); + await client.set(user3, value); + + limiter.reset(); + + const resetUser1 = await client.get(user1); + const resetUser2 = await client.get(user2); + const resetUser3 = await client.get(user3); + expect(resetUser1).toBe(null); + expect(resetUser2).toBe(null); + expect(resetUser3).toBe(null); + }); + }); +}); From 9d1d2247f44e1c700fe35dfb8e5a2f15dfa6dbc6 Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Thu, 14 Jul 2022 23:02:36 -0700 Subject: [PATCH 03/16] lint errors fixed --- src/rateLimiters/slidingWindowCounter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index 159cef5..5052fce 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -105,7 +105,7 @@ class SlidingWindowCounter implements RateLimiter { const window: RedisWindow = await JSON.parse(windowJSON); - let updatedUserWindow: RedisWindow = { + const updatedUserWindow: RedisWindow = { currentTokens: window.currentTokens, previousTokens: window.previousTokens, fixedWindowStart: window.fixedWindowStart, @@ -120,7 +120,7 @@ class SlidingWindowCounter implements RateLimiter { // assigned to avoid TS error, this var will never be used as 0 // var is declared here so that below can be inside a conditional for efficiency's sake - let rollingWindowProportion: number = 0; + let rollingWindowProportion = 0; if (updatedUserWindow.previousTokens) { // subtract window size by current time less fixed window's start @@ -133,7 +133,7 @@ class SlidingWindowCounter implements RateLimiter { this.windowSize; // remove unecessary decimals, 0.xx is enough - rollingWindowProportion = rollingWindowProportion - (rollingWindowProportion % 0.01); + rollingWindowProportion -= rollingWindowProportion % 0.01; } // the sliding window counter formula From a5aa9d3ca9fb287b5c6ab68ef7af1c56f4f1f20e Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Fri, 15 Jul 2022 02:01:17 -0700 Subject: [PATCH 04/16] updated tests and tweaked sliding window to pass all tests --- src/rateLimiters/slidingWindowCounter.ts | 43 +++++++++---------- .../rateLimiters/slidingWindowCounter.test.ts | 23 +++++----- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index 5052fce..44921c3 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -91,14 +91,14 @@ class SlidingWindowCounter implements RateLimiter { fixedWindowStart: timestamp, }; - if (tokens > this.capacity) { + if (tokens <= this.capacity) { await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow)); - // tokens property represents how much capacity remains - return { success: false, tokens: this.capacity }; + return { success: true, tokens: this.capacity - newUserWindow.currentTokens }; } await this.client.setex(uuid, keyExpiry, JSON.stringify(newUserWindow)); - return { success: true, tokens: this.capacity - newUserWindow.currentTokens }; + // tokens property represents how much capacity remains + return { success: false, tokens: this.capacity }; } // if the cache is populated @@ -112,45 +112,44 @@ class SlidingWindowCounter implements RateLimiter { }; // if request time is in a new window - if (timestamp > window.fixedWindowStart + this.windowSize + 1) { + if (timestamp >= window.fixedWindowStart + this.windowSize + 1) { updatedUserWindow.previousTokens = updatedUserWindow.currentTokens; updatedUserWindow.currentTokens = 0; - updatedUserWindow.fixedWindowStart = window.fixedWindowStart + this.windowSize; + updatedUserWindow.fixedWindowStart = window.fixedWindowStart + this.windowSize + 1; } // assigned to avoid TS error, this var will never be used as 0 // var is declared here so that below can be inside a conditional for efficiency's sake let rollingWindowProportion = 0; + let previousRollingTokens = 0; if (updatedUserWindow.previousTokens) { - // subtract window size by current time less fixed window's start - // current time less fixed window start is the amount that rolling window is in current window - // to get amount that rolling window is in previous window, we subtract this difference by window size - // then we divide this amount by window size to get the proportion of rolling window in previous window - // ex. 60000 - (1million+5 - 1million) = 59995 / 60000 = 0.9999 + // proportion of rolling window present in previous window rollingWindowProportion = (this.windowSize - (timestamp - updatedUserWindow.fixedWindowStart)) / this.windowSize; // remove unecessary decimals, 0.xx is enough rollingWindowProportion -= rollingWindowProportion % 0.01; + + // # of tokens present in rolling & previous window + previousRollingTokens = Math.floor( + updatedUserWindow.previousTokens * rollingWindowProportion + ); } - // the sliding window counter formula - // ex. tokens(1) + currentTokens(2) + previousTokens(4) * RWP(.75) = 6 < capacity(10) - // adjusts formula if previousTokens is null - const rollingWindowAllowal = updatedUserWindow.previousTokens - ? tokens + - updatedUserWindow.currentTokens + - updatedUserWindow.previousTokens * rollingWindowProportion <= - this.capacity - : tokens + updatedUserWindow.currentTokens <= this.capacity; + // # of tokens present in rolling and/or current window + // if previous tokens is null, previousRollingTokens will be 0 + const rollingTokens = updatedUserWindow.currentTokens + previousRollingTokens; // if request is allowed - if (rollingWindowAllowal) { + if (tokens + rollingTokens <= this.capacity) { updatedUserWindow.currentTokens += tokens; await this.client.setex(uuid, keyExpiry, JSON.stringify(updatedUserWindow)); - return { success: true, tokens: this.capacity - updatedUserWindow.currentTokens }; + return { + success: true, + tokens: this.capacity - (updatedUserWindow.currentTokens + previousRollingTokens), + }; } // if request is blocked diff --git a/test/rateLimiters/slidingWindowCounter.test.ts b/test/rateLimiters/slidingWindowCounter.test.ts index 5b2bf05..aa4e79f 100644 --- a/test/rateLimiters/slidingWindowCounter.test.ts +++ b/test/rateLimiters/slidingWindowCounter.test.ts @@ -91,6 +91,7 @@ describe('Test TokenBucket Rate Limiter', () => { await setTokenCountInClient(client, user4, 10, null, timestamp); // tokens returned in processRequest is equal to the capacity // still available in the fixed window + expect( (await limiter.processRequest(user4, timestamp + WINDOW_SIZE + 1, 1)).tokens ).toBe(0); // here, we expect the rolling window to only allow 1 token, b/c @@ -199,8 +200,8 @@ describe('Test TokenBucket Rate Limiter', () => { (await limiter.processRequest(user2, timestamp + WINDOW_SIZE, 5)).tokens ).toBe(CAPACITY - initRequest); - // expect current tokens in the window to still be 0 - expect((await getWindowFromClient(client, user2)).currentTokens).toBe(0); + // expect current tokens in the window to still be 6 + expect((await getWindowFromClient(client, user2)).currentTokens).toBe(6); }); // 3 rolling window tests with different proportions (.25, .5, .75) @@ -276,7 +277,7 @@ describe('Test TokenBucket Rate Limiter', () => { // currentTokens (in current fixed window): 0 // previousTokens (in previous fixed window): 8 const count = await getWindowFromClient(client, user4); - expect(count.currentTokens).toBe(4); + expect(count.currentTokens).toBe(0); expect(count.previousTokens).toBe(initRequest); }); }); @@ -321,20 +322,19 @@ describe('Test TokenBucket Rate Limiter', () => { }); test('fixed window and current/previous tokens update as expected', async () => { - await setTokenCountInClient(client, user1, 0, null, timestamp); - // fills first window with 4 tokens + // fills first window with 5 tokens await limiter.processRequest(user1, timestamp, 5); - // fills second window with 5 tokens + // fills second window with 4 tokens expect( await ( await limiter.processRequest(user1, timestamp + WINDOW_SIZE + 1, 4) ).tokens - ).toBe(6); + ).toBe(2); // currentTokens (in current fixed window): 0 // previousTokens (in previous fixed window): 8 - const count = await getWindowFromClient(client, user4); + const count = await getWindowFromClient(client, user1); // ensures that fixed window is updated when a request goes over - expect(count.fixedWindowStart).toBe(timestamp + WINDOW_SIZE); + expect(count.fixedWindowStart).toBe(timestamp + WINDOW_SIZE + 1); // ensures that previous tokens property updates on fixed window change expect(count.previousTokens).toBe(5); // ensures that current tokens only represents tokens from current window requests @@ -348,10 +348,11 @@ describe('Test TokenBucket Rate Limiter', () => { await newLimiter.processRequest(user1, timestamp, 8); - // expect that a new window is entered, leaving 9 tokens available after a 1 token request + // expect that a new window is entered, leaving 2 tokens available after both requests + // 8 * .99 -> 7 (floored) + 1 = 8 expect( (await newLimiter.processRequest(user1, timestamp + newWindowSize + 1, 1)).tokens - ).toBe(9); + ).toBe(2); }); test('sliding window allows custom capacities', async () => { From f62133da180479b1311439941807ffd2a879653d Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Tue, 19 Jul 2022 21:40:44 -0700 Subject: [PATCH 05/16] removed null on previousTokens --- src/@types/rateLimit.d.ts | 3 +- .../rateLimiters/slidingWindowCounter.test.ts | 30 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 74331f4..5b1b975 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -25,8 +25,7 @@ export interface RedisBucket { export interface RedisWindow { currentTokens: number; - // null if limiter is currently on the initial fixed window - previousTokens: number | null; + previousTokens: number; fixedWindowStart: number; } diff --git a/test/rateLimiters/slidingWindowCounter.test.ts b/test/rateLimiters/slidingWindowCounter.test.ts index aa4e79f..4d56a04 100644 --- a/test/rateLimiters/slidingWindowCounter.test.ts +++ b/test/rateLimiters/slidingWindowCounter.test.ts @@ -28,7 +28,7 @@ async function setTokenCountInClient( redisClient: ioredis.Redis, uuid: string, currentTokens: number, - previousTokens: number | null, + previousTokens: number, fixedWindowStart: number ) { const value: RedisWindow = { currentTokens, previousTokens, fixedWindowStart }; @@ -78,7 +78,7 @@ describe('Test TokenBucket Rate Limiter', () => { // window partially full and no leftover tokens after request test('fixed window is partially full and request has no leftover tokens', async () => { const initial = 6; - await setTokenCountInClient(client, user2, initial, null, timestamp); + await setTokenCountInClient(client, user2, initial, 0, timestamp); expect( (await limiter.processRequest(user2, timestamp, CAPACITY - initial)).tokens ).toBe(0); @@ -88,7 +88,7 @@ describe('Test TokenBucket Rate Limiter', () => { // Window initially full but enough time elapsed to paritally fill window since last request test('fixed window is initially full but after new fixed window is initialized request is allowed', async () => { - await setTokenCountInClient(client, user4, 10, null, timestamp); + await setTokenCountInClient(client, user4, 10, 0, timestamp); // tokens returned in processRequest is equal to the capacity // still available in the fixed window @@ -112,7 +112,7 @@ describe('Test TokenBucket Rate Limiter', () => { // to set rolling window at 75% of previous fixed window) // to set initial fixedWindowStart - await setTokenCountInClient(client, user4, 0, null, timestamp); + await setTokenCountInClient(client, user4, 0, 0, timestamp); // large request at very end of first fixed window await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 8); @@ -136,7 +136,7 @@ describe('Test TokenBucket Rate Limiter', () => { // to set rolling window at 50% of previous fixed window) // to set initial fixedWindowStart - await setTokenCountInClient(client, user4, 0, null, timestamp); + await setTokenCountInClient(client, user4, 0, 0, timestamp); // large request at very end of first fixed window await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 8); @@ -160,7 +160,7 @@ describe('Test TokenBucket Rate Limiter', () => { // to set rolling window at 25% of previous fixed window) // to set initial fixedWindowStart - await setTokenCountInClient(client, user4, 0, null, timestamp); + await setTokenCountInClient(client, user4, 0, 0, timestamp); // large request at very end of first fixed window await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 8); @@ -194,7 +194,7 @@ describe('Test TokenBucket Rate Limiter', () => { test('window is partially full but not enough time elapsed to reach new window', async () => { const initRequest = 6; - await setTokenCountInClient(client, user2, initRequest, null, timestamp); + await setTokenCountInClient(client, user2, initRequest, 0, timestamp); // expect remaining tokens to be 4, b/c the 5 token request should be blocked expect( (await limiter.processRequest(user2, timestamp + WINDOW_SIZE, 5)).tokens @@ -211,7 +211,7 @@ describe('Test TokenBucket Rate Limiter', () => { // to set rolling window at 75% of previous fixed window) // to set initial fixedWindowStart - await setTokenCountInClient(client, user4, 0, null, timestamp); + await setTokenCountInClient(client, user4, 0, 0, timestamp); const initRequest = 8; @@ -237,7 +237,7 @@ describe('Test TokenBucket Rate Limiter', () => { // to set rolling window at 50% of previous fixed window) // to set initial fixedWindowStart - await setTokenCountInClient(client, user4, 0, null, timestamp); + await setTokenCountInClient(client, user4, 0, 0, timestamp); const initRequest = 8; @@ -262,7 +262,7 @@ describe('Test TokenBucket Rate Limiter', () => { // to set rolling window at 25% of previous fixed window) // to set initial fixedWindowStart - await setTokenCountInClient(client, user4, 0, null, timestamp); + await setTokenCountInClient(client, user4, 0, 0, timestamp); const initRequest = 8; @@ -309,7 +309,7 @@ describe('Test TokenBucket Rate Limiter', () => { // Fill up user 1's window const value: RedisWindow = { currentTokens: 10, - previousTokens: null, + previousTokens: 0, fixedWindowStart: timestamp, }; await client.set(user1, JSON.stringify(value)); @@ -368,7 +368,7 @@ describe('Test TokenBucket Rate Limiter', () => { const requested = 6; const user3Tokens = 8; // Add tokens for user 3 so we have both a user that exists in the store (3) and one that doesn't (2) - await setTokenCountInClient(client, user3, user3Tokens, null, timestamp); + await setTokenCountInClient(client, user3, user3Tokens, 0, timestamp); // issue a request for user 1; await limiter.processRequest(user1, timestamp, requested); @@ -402,9 +402,9 @@ describe('Test TokenBucket Rate Limiter', () => { test('all windows should be able to be reset', async () => { const tokens = 5; - await setTokenCountInClient(client, user1, tokens, null, timestamp); - await setTokenCountInClient(client, user2, tokens, null, timestamp); - await setTokenCountInClient(client, user3, tokens, null, timestamp); + await setTokenCountInClient(client, user1, tokens, 0, timestamp); + await setTokenCountInClient(client, user2, tokens, 0, timestamp); + await setTokenCountInClient(client, user3, tokens, 0, timestamp); limiter.reset(); From ff74d9055ea55a2eacd119fcf115b7000d01c64e Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Tue, 19 Jul 2022 21:43:29 -0700 Subject: [PATCH 06/16] extra removal of null --- src/rateLimiters/slidingWindowCounter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index 44921c3..3e0a549 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -87,7 +87,7 @@ class SlidingWindowCounter implements RateLimiter { const newUserWindow: RedisWindow = { // current and previous tokens represent how many tokens are in each window currentTokens: tokens <= this.capacity ? tokens : 0, - previousTokens: null, + previousTokens: 0, fixedWindowStart: timestamp, }; From 9df25e9b6f27124afbafc3d3dcd7c8e694c56972 Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Tue, 19 Jul 2022 22:04:21 -0700 Subject: [PATCH 07/16] changed new window start to equal old window end --- src/rateLimiters/slidingWindowCounter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index 3e0a549..c1fbf84 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -112,10 +112,10 @@ class SlidingWindowCounter implements RateLimiter { }; // if request time is in a new window - if (timestamp >= window.fixedWindowStart + this.windowSize + 1) { + if (timestamp >= window.fixedWindowStart + this.windowSize) { updatedUserWindow.previousTokens = updatedUserWindow.currentTokens; updatedUserWindow.currentTokens = 0; - updatedUserWindow.fixedWindowStart = window.fixedWindowStart + this.windowSize + 1; + updatedUserWindow.fixedWindowStart = window.fixedWindowStart + this.windowSize; } // assigned to avoid TS error, this var will never be used as 0 From 955d0c594b86ca833bdd8967583e166804acf6aa Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Tue, 19 Jul 2022 22:48:38 -0700 Subject: [PATCH 08/16] updated tests --- .../rateLimiters/slidingWindowCounter.test.ts | 223 +++++++++++++++--- 1 file changed, 188 insertions(+), 35 deletions(-) diff --git a/test/rateLimiters/slidingWindowCounter.test.ts b/test/rateLimiters/slidingWindowCounter.test.ts index 4d56a04..75a8d90 100644 --- a/test/rateLimiters/slidingWindowCounter.test.ts +++ b/test/rateLimiters/slidingWindowCounter.test.ts @@ -50,6 +50,17 @@ describe('Test TokenBucket Rate Limiter', () => { afterEach(() => { client.flushall(); }); + test('fixed window and cache are initially empty', async () => { + // window is intially empty + const withdraw5 = 5; + expect((await limiter.processRequest(user1, timestamp, withdraw5)).tokens).toBe( + CAPACITY - withdraw5 + ); + const tokenCountFull = await getWindowFromClient(client, user1); + expect(tokenCountFull.currentTokens).toBe(CAPACITY - withdraw5); + expect(tokenCountFull.previousTokens).toBe(0); + }); + test('fixed window is initially empty', async () => { // window is intially empty const withdraw5 = 5; @@ -58,6 +69,7 @@ describe('Test TokenBucket Rate Limiter', () => { ); const tokenCountFull = await getWindowFromClient(client, user1); expect(tokenCountFull.currentTokens).toBe(CAPACITY - withdraw5); + expect(tokenCountFull.previousTokens).toBe(0); }); test('fixed window is partially full and request has leftover tokens', async () => { @@ -92,9 +104,16 @@ describe('Test TokenBucket Rate Limiter', () => { // tokens returned in processRequest is equal to the capacity // still available in the fixed window - expect( - (await limiter.processRequest(user4, timestamp + WINDOW_SIZE + 1, 1)).tokens - ).toBe(0); // here, we expect the rolling window to only allow 1 token, b/c + // adds additional ms so that: + // rolling window proportion: .99999... + // 1 + 10 * .9 = 10 (floored) + const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE + 1, 1); + + // should be allowed because formula is floored + expect(result.success).toBe(true); + expect(result.tokens).toBe(0); + + // here, we expect the rolling window to only allow 1 token, b/c // only 1ms has passed since the previous fixed window // `currentTokens` cached is the amount of tokens @@ -105,7 +124,31 @@ describe('Test TokenBucket Rate Limiter', () => { expect(count.currentTokens).toBe(1); }); - // three different tests within, with different rolling window proportions (.25, .5, .75) + // five different tests within, with different rolling window proportions (0.01, .25, .5, .75, 1) + test('rolling window at 100% allows requests under capacity', async () => { + // 100% of rolling window present in previous fixed window + // 1*60000 = 60000 (time after initial fixedWindowStart + // to set rolling window at 100% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, 0, timestamp); + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, 8); + + // 2 + 8 * 1 = 10, right at capacity (request should be allowed) + // tokens until capacity: 0 (tokens property returned by processRequest method) + const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 2); + expect(result.tokens).toBe(0); + expect(result.success).toBe(true); + + // currentTokens (in current fixed window): 4 + // previousTokens (in previous fixed window): 8 + const count1 = await getWindowFromClient(client, user4); + expect(count1.currentTokens).toBe(2); + expect(count1.previousTokens).toBe(8); + }); + test('rolling window at 75% allows requests under capacity', async () => { // 75% of rolling window present in previous fixed window // 1.25*60000 = 75000 (time after initial fixedWindowStart @@ -115,13 +158,17 @@ describe('Test TokenBucket Rate Limiter', () => { await setTokenCountInClient(client, user4, 0, 0, timestamp); // large request at very end of first fixed window - await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 8); + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, 8); // 4 + 8 * .75 = 10, right at capacity (request should be allowed) // tokens until capacity: 0 (tokens property returned by processRequest method) - expect( - (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.25, 4)).tokens - ).toBe(0); + const result = await limiter.processRequest( + user4, + timestamp + WINDOW_SIZE * 1.25, + 4 + ); + expect(result.tokens).toBe(0); + expect(result.success).toBe(true); // currentTokens (in current fixed window): 4 // previousTokens (in previous fixed window): 8 @@ -139,13 +186,17 @@ describe('Test TokenBucket Rate Limiter', () => { await setTokenCountInClient(client, user4, 0, 0, timestamp); // large request at very end of first fixed window - await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 8); + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, 8); // 4 + 8 * .5 = 8, under capacity (request should be allowed) // tokens until capacity: 2 (tokens property returned by processRequest method) - expect( - (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.5, 4)).tokens - ).toBe(2); + const result = await limiter.processRequest( + user4, + timestamp + WINDOW_SIZE * 1.5, + 4 + ); + expect(result.tokens).toBe(2); + expect(result.success).toBe(true); // currentTokens (in current fixed window): 4 // previousTokens (in previous fixed window): 8 @@ -163,13 +214,17 @@ describe('Test TokenBucket Rate Limiter', () => { await setTokenCountInClient(client, user4, 0, 0, timestamp); // large request at very end of first fixed window - await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 8); + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, 8); // 4 + 8 * .25 = 6, under capacity (request should be allowed) // tokens until capacity: 4 (tokens property returned by processRequest method) - expect( - (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.75, 4)).tokens - ).toBe(4); + const result = await limiter.processRequest( + user4, + timestamp + WINDOW_SIZE * 1.75, + 4 + ); + expect(result.tokens).toBe(4); + expect(result.success).toBe(true); // currentTokens (in current fixed window): 4 // previousTokens (in previous fixed window): 8 @@ -177,6 +232,34 @@ describe('Test TokenBucket Rate Limiter', () => { expect(count.currentTokens).toBe(4); expect(count.previousTokens).toBe(8); }); + + test('rolling window at 1% allows requests under capacity', async () => { + // 1% of rolling window present in previous fixed window + // 0.01*60000 = 600 (time after initial fixedWindowStart + // to set rolling window at 1% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, 0, timestamp); + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, 8); + + // 10 + 8 * .01 = 10, right at capacity (request should be allowed) + // tokens until capacity: 0 (tokens property returned by processRequest method) + const result = await limiter.processRequest( + user4, + timestamp + WINDOW_SIZE * 1.99, + 4 + ); + expect(result.tokens).toBe(0); + expect(result.success).toBe(true); + + // currentTokens (in current fixed window): 4 + // previousTokens (in previous fixed window): 8 + const count1 = await getWindowFromClient(client, user4); + expect(count1.currentTokens).toBe(4); + expect(count1.previousTokens).toBe(8); + }); }); describe('after a BLOCKED request...', () => { @@ -196,15 +279,40 @@ describe('Test TokenBucket Rate Limiter', () => { await setTokenCountInClient(client, user2, initRequest, 0, timestamp); // expect remaining tokens to be 4, b/c the 5 token request should be blocked - expect( - (await limiter.processRequest(user2, timestamp + WINDOW_SIZE, 5)).tokens - ).toBe(CAPACITY - initRequest); + const result = await limiter.processRequest(user2, timestamp + WINDOW_SIZE - 1, 5); + + expect(result.success).toBe(false); + expect(result.tokens).toBe(CAPACITY - initRequest); // expect current tokens in the window to still be 6 expect((await getWindowFromClient(client, user2)).currentTokens).toBe(6); }); - // 3 rolling window tests with different proportions (.25, .5, .75) + // 5 rolling window tests with different proportions (.01, .25, .5, .75, 1) + test('rolling window at 100% blocks requests over allowed limit set by formula', async () => { + // 100% of rolling window present in previous fixed window + // 1*60000 = 60000 (time after initial fixedWindowStart + // to set rolling window at 100% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, 0, timestamp); + + const initRequest = 8; + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); + + // 3 + 8 * 1 = 11, above capacity (request should be blocked) + const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 3); + expect(result.tokens).toBe(10); + expect(result.success).toBe(false); + + // currentTokens (in current fixed window): 0 + // previousTokens (in previous fixed window): 8 + const count1 = await getWindowFromClient(client, user4); + expect(count1.currentTokens).toBe(0); + expect(count1.previousTokens).toBe(initRequest); + }); test('rolling window at 75% blocks requests over allowed limit set by formula', async () => { // 75% of rolling window present in previous fixed window // 1.25*60000 = 75000 (time after initial fixedWindowStart @@ -216,12 +324,16 @@ describe('Test TokenBucket Rate Limiter', () => { const initRequest = 8; // large request at very end of first fixed window - await limiter.processRequest(user4, timestamp + WINDOW_SIZE, initRequest); + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); // 5 + 8 * .75 = 11, above capacity (request should be blocked) - expect( - (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.25, 5)).tokens - ).toBe(10); + const result = await limiter.processRequest( + user4, + timestamp + WINDOW_SIZE * 1.25, + 5 + ); + expect(result.tokens).toBe(10); + expect(result.success).toBe(false); // currentTokens (in current fixed window): 0 // previousTokens (in previous fixed window): 8 @@ -242,12 +354,12 @@ describe('Test TokenBucket Rate Limiter', () => { const initRequest = 8; // large request at very end of first fixed window - await limiter.processRequest(user4, timestamp + WINDOW_SIZE, initRequest); + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); // 7 + 8 * .5 = 11, over capacity (request should be blocked) - expect( - (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.5, 7)).tokens - ).toBe(10); + const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.5, 7); + expect(result.tokens).toBe(10); + expect(result.success).toBe(false); // currentTokens (in current fixed window): 0 // previousTokens (in previous fixed window): 8 @@ -267,12 +379,12 @@ describe('Test TokenBucket Rate Limiter', () => { const initRequest = 8; // large request at very end of first fixed window - await limiter.processRequest(user4, timestamp + WINDOW_SIZE, initRequest); + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); // 9 + 8 * .25 = 11, over capacity (request should be blocked) - expect( - (await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.75, 9)).tokens - ).toBe(10); + const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.75, 9); + expect(result.tokens).toBe(10); + expect(result.success).toBe(false); // currentTokens (in current fixed window): 0 // previousTokens (in previous fixed window): 8 @@ -280,6 +392,30 @@ describe('Test TokenBucket Rate Limiter', () => { expect(count.currentTokens).toBe(0); expect(count.previousTokens).toBe(initRequest); }); + test('rolling window at 100% blocks requests over allowed limit set by formula', async () => { + // 1% of rolling window present in previous fixed window + // .01*60000 = 600 (time after initial fixedWindowStart + // to set rolling window at 100% of previous fixed window) + + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, 0, timestamp); + + const initRequest = 8; + + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); + + // 11 + 8 * .01 = 11, above capacity (request should be blocked) + const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 11); + expect(result.tokens).toBe(10); + expect(result.success).toBe(false); + + // currentTokens (in current fixed window): 0 + // previousTokens (in previous fixed window): 8 + const count1 = await getWindowFromClient(client, user4); + expect(count1.currentTokens).toBe(0); + expect(count1.previousTokens).toBe(initRequest); + }); }); describe('SlidingWindowCounter functions as expected', () => { @@ -327,14 +463,14 @@ describe('Test TokenBucket Rate Limiter', () => { // fills second window with 4 tokens expect( await ( - await limiter.processRequest(user1, timestamp + WINDOW_SIZE + 1, 4) + await limiter.processRequest(user1, timestamp + WINDOW_SIZE, 4) ).tokens ).toBe(2); // currentTokens (in current fixed window): 0 // previousTokens (in previous fixed window): 8 const count = await getWindowFromClient(client, user1); // ensures that fixed window is updated when a request goes over - expect(count.fixedWindowStart).toBe(timestamp + WINDOW_SIZE + 1); + expect(count.fixedWindowStart).toBe(timestamp + WINDOW_SIZE); // ensures that previous tokens property updates on fixed window change expect(count.previousTokens).toBe(5); // ensures that current tokens only represents tokens from current window requests @@ -416,6 +552,21 @@ describe('Test TokenBucket Rate Limiter', () => { false ); }); + + test('updates correctly when > WINDOW_SIZE * 2 has surpassed', async () => { + await setTokenCountInClient(client, user1, 1, 0, timestamp); + + // to make sure that previous tokens is not 1 + const result = await limiter.processRequest(user1, timestamp + WINDOW_SIZE * 2, 1); + + expect(result.tokens).toBe(9); + + const redisData: RedisWindow = await getWindowFromClient(client, user1); + + expect(redisData.currentTokens).toBe(1); + expect(redisData.previousTokens).toBe(0); + expect(redisData.fixedWindowStart).toBe(timestamp + WINDOW_SIZE * 2); + }); }); describe('SlidingWindowCounter correctly updates Redis cache', () => { @@ -447,12 +598,14 @@ describe('Test TokenBucket Rate Limiter', () => { expect(redisData.currentTokens).toBe(2); - await limiter.processRequest(user1, timestamp + WINDOW_SIZE + 1, 3); + // new window + await limiter.processRequest(user1, timestamp + WINDOW_SIZE, 3); redisData = await getWindowFromClient(client, user1); expect(redisData.currentTokens).toBe(3); expect(redisData.previousTokens).toBe(2); + expect(redisData.fixedWindowStart).toBe(timestamp + WINDOW_SIZE); }); test('all windows should be able to be reset', async () => { From 3460adcc68bf3424b767cea7bfae7e44844bb0f4 Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Tue, 19 Jul 2022 23:00:39 -0700 Subject: [PATCH 09/16] added functionality for skipped windows --- src/rateLimiters/slidingWindowCounter.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index c1fbf84..f505b01 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -113,9 +113,21 @@ class SlidingWindowCounter implements RateLimiter { // if request time is in a new window if (timestamp >= window.fixedWindowStart + this.windowSize) { - updatedUserWindow.previousTokens = updatedUserWindow.currentTokens; - updatedUserWindow.currentTokens = 0; - updatedUserWindow.fixedWindowStart = window.fixedWindowStart + this.windowSize; + // calculates how many windows may have been skipped since last request + const windowsSkipped = Math.floor( + (timestamp - window.fixedWindowStart) / this.windowSize + ); + // if more than one window was skipped + if (windowsSkipped > 1) { + updatedUserWindow.previousTokens = 0; + updatedUserWindow.currentTokens = 0; + updatedUserWindow.fixedWindowStart = + window.fixedWindowStart + this.windowSize * windowsSkipped; + } else { + updatedUserWindow.previousTokens = updatedUserWindow.currentTokens; + updatedUserWindow.currentTokens = 0; + updatedUserWindow.fixedWindowStart = window.fixedWindowStart + this.windowSize; + } } // assigned to avoid TS error, this var will never be used as 0 From 4455db1ed182c8ddab5d8e0c7b1e29390fe4378f Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Tue, 19 Jul 2022 23:08:39 -0700 Subject: [PATCH 10/16] updated types --- src/@types/rateLimit.d.ts | 14 +++++++++++++- src/rateLimiters/slidingWindowCounter.ts | 5 ++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 5b1b975..37862cb 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -45,7 +45,19 @@ export interface TokenBucketOptions { refillRate: number; } +/** + * @type {number} windowSize - Size of each fixed window and the rolling window + * @type {number} capacity - Number of tokens a window can hold + */ +export interface SlidingWindowCounterOptions { + windowSize: number; + capacity: number; +} + // TODO: This will be a union type where we can specify Option types for other Rate Limiters // Record represents the empty object for alogorithms that don't require settings // and might be able to be removed in the future. -export type RateLimiterOptions = TokenBucketOptions | Record; +export type RateLimiterOptions = + | TokenBucketOptions + | SlidingWindowCounterOptions + | Record; diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index f505b01..ee2b484 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -166,7 +166,10 @@ class SlidingWindowCounter implements RateLimiter { // if request is blocked await this.client.setex(uuid, keyExpiry, JSON.stringify(updatedUserWindow)); - return { success: false, tokens: this.capacity - updatedUserWindow.currentTokens }; + return { + success: false, + tokens: this.capacity - (updatedUserWindow.currentTokens + previousRollingTokens), + }; } /** From dd9df4a77ce547465e444df23bd4ef2bda64fc7a Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Tue, 19 Jul 2022 23:11:03 -0700 Subject: [PATCH 11/16] added sliding window counter to ratelimitersetup --- src/middleware/rateLimiterSetup.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/middleware/rateLimiterSetup.ts b/src/middleware/rateLimiterSetup.ts index 734bc5f..be7f57d 100644 --- a/src/middleware/rateLimiterSetup.ts +++ b/src/middleware/rateLimiterSetup.ts @@ -1,5 +1,6 @@ import Redis from 'ioredis'; import { RateLimiterOptions, RateLimiterSelection } from '../@types/rateLimit'; +import SlidingWindowCounter from '../rateLimiters/slidingWindowCounter'; import TokenBucket from '../rateLimiters/tokenBucket'; /** @@ -31,7 +32,7 @@ export default function setupRateLimiter( throw new Error('Sliding Window Log has not be implemented.'); break; case 'SLIDING_WINDOW_COUNTER': - throw new Error('Sliding Window Counter algonithm has not be implemented.'); + return new SlidingWindowCounter(options.windowSize, options.capacity, client); break; default: // typescript should never let us invoke this function with anything other than the options above From bd82902309ff34d54c5b589d24ff810859147d0f Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Wed, 20 Jul 2022 00:17:21 -0700 Subject: [PATCH 12/16] failing tests updated --- src/rateLimiters/slidingWindowCounter.ts | 12 +- .../rateLimiters/slidingWindowCounter.test.ts | 132 ++++++++++-------- 2 files changed, 76 insertions(+), 68 deletions(-) diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index ee2b484..62f8f3a 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -113,12 +113,12 @@ class SlidingWindowCounter implements RateLimiter { // if request time is in a new window if (timestamp >= window.fixedWindowStart + this.windowSize) { - // calculates how many windows may have been skipped since last request - const windowsSkipped = Math.floor( - (timestamp - window.fixedWindowStart) / this.windowSize - ); // if more than one window was skipped - if (windowsSkipped > 1) { + if (timestamp >= window.fixedWindowStart + this.windowSize * 2) { + // calculates how many windows may have been skipped since last request + const windowsSkipped = Math.floor( + (timestamp - window.fixedWindowStart) / this.windowSize + ); updatedUserWindow.previousTokens = 0; updatedUserWindow.currentTokens = 0; updatedUserWindow.fixedWindowStart = @@ -142,7 +142,7 @@ class SlidingWindowCounter implements RateLimiter { this.windowSize; // remove unecessary decimals, 0.xx is enough - rollingWindowProportion -= rollingWindowProportion % 0.01; + // rollingWindowProportion -= rollingWindowProportion % 0.01; // # of tokens present in rolling & previous window previousRollingTokens = Math.floor( diff --git a/test/rateLimiters/slidingWindowCounter.test.ts b/test/rateLimiters/slidingWindowCounter.test.ts index 75a8d90..e1c6dc8 100644 --- a/test/rateLimiters/slidingWindowCounter.test.ts +++ b/test/rateLimiters/slidingWindowCounter.test.ts @@ -249,7 +249,7 @@ describe('Test TokenBucket Rate Limiter', () => { const result = await limiter.processRequest( user4, timestamp + WINDOW_SIZE * 1.99, - 4 + 10 ); expect(result.tokens).toBe(0); expect(result.success).toBe(true); @@ -257,7 +257,7 @@ describe('Test TokenBucket Rate Limiter', () => { // currentTokens (in current fixed window): 4 // previousTokens (in previous fixed window): 8 const count1 = await getWindowFromClient(client, user4); - expect(count1.currentTokens).toBe(4); + expect(count1.currentTokens).toBe(10); expect(count1.previousTokens).toBe(8); }); }); @@ -304,7 +304,7 @@ describe('Test TokenBucket Rate Limiter', () => { // 3 + 8 * 1 = 11, above capacity (request should be blocked) const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 3); - expect(result.tokens).toBe(10); + expect(result.tokens).toBe(2); expect(result.success).toBe(false); // currentTokens (in current fixed window): 0 @@ -332,7 +332,7 @@ describe('Test TokenBucket Rate Limiter', () => { timestamp + WINDOW_SIZE * 1.25, 5 ); - expect(result.tokens).toBe(10); + expect(result.tokens).toBe(4); expect(result.success).toBe(false); // currentTokens (in current fixed window): 0 @@ -341,80 +341,88 @@ describe('Test TokenBucket Rate Limiter', () => { expect(count1.currentTokens).toBe(0); expect(count1.previousTokens).toBe(initRequest); }); - }); - test('rolling window at 50% blocks requests over allowed limit set by formula', async () => { - // 50% of rolling window present in previous fixed window - // 1.5*60000 = 90000 (time after initial fixedWindowStart - // to set rolling window at 50% of previous fixed window) + test('rolling window at 50% blocks requests over allowed limit set by formula', async () => { + // 50% of rolling window present in previous fixed window + // 1.5*60000 = 90000 (time after initial fixedWindowStart + // to set rolling window at 50% of previous fixed window) - // to set initial fixedWindowStart - await setTokenCountInClient(client, user4, 0, 0, timestamp); + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, 0, timestamp); - const initRequest = 8; + const initRequest = 8; - // large request at very end of first fixed window - await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); - // 7 + 8 * .5 = 11, over capacity (request should be blocked) - const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.5, 7); - expect(result.tokens).toBe(10); - expect(result.success).toBe(false); + // 7 + 8 * .5 = 11, over capacity (request should be blocked) + const result = await limiter.processRequest( + user4, + timestamp + WINDOW_SIZE * 1.5, + 7 + ); + expect(result.tokens).toBe(6); + expect(result.success).toBe(false); - // currentTokens (in current fixed window): 0 - // previousTokens (in previous fixed window): 8 - const count = await getWindowFromClient(client, user4); - expect(count.currentTokens).toBe(0); - expect(count.previousTokens).toBe(initRequest); - }); + // currentTokens (in current fixed window): 0 + // previousTokens (in previous fixed window): 8 + const count = await getWindowFromClient(client, user4); + expect(count.currentTokens).toBe(0); + expect(count.previousTokens).toBe(initRequest); + }); - test('rolling window at 25% blocks requests over allowed limit set by formula', async () => { - // 25% of rolling window present in previous fixed window - // 1.75*60000 = 105000 (time after initial fixedWindowStart - // to set rolling window at 25% of previous fixed window) + test('rolling window at 25% blocks requests over allowed limit set by formula', async () => { + // 25% of rolling window present in previous fixed window + // 1.75*60000 = 105000 (time after initial fixedWindowStart + // to set rolling window at 25% of previous fixed window) - // to set initial fixedWindowStart - await setTokenCountInClient(client, user4, 0, 0, timestamp); + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, 0, timestamp); - const initRequest = 8; + const initRequest = 8; - // large request at very end of first fixed window - await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); - // 9 + 8 * .25 = 11, over capacity (request should be blocked) - const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE * 1.75, 9); - expect(result.tokens).toBe(10); - expect(result.success).toBe(false); + // 9 + 8 * .25 = 11, over capacity (request should be blocked) + const result = await limiter.processRequest( + user4, + timestamp + WINDOW_SIZE * 1.75, + 9 + ); + expect(result.tokens).toBe(8); + expect(result.success).toBe(false); - // currentTokens (in current fixed window): 0 - // previousTokens (in previous fixed window): 8 - const count = await getWindowFromClient(client, user4); - expect(count.currentTokens).toBe(0); - expect(count.previousTokens).toBe(initRequest); - }); - test('rolling window at 100% blocks requests over allowed limit set by formula', async () => { - // 1% of rolling window present in previous fixed window - // .01*60000 = 600 (time after initial fixedWindowStart - // to set rolling window at 100% of previous fixed window) + // currentTokens (in current fixed window): 0 + // previousTokens (in previous fixed window): 8 + const count = await getWindowFromClient(client, user4); + expect(count.currentTokens).toBe(0); + expect(count.previousTokens).toBe(initRequest); + }); + test('rolling window at 100% blocks requests over allowed limit set by formula', async () => { + // 1% of rolling window present in previous fixed window + // .01*60000 = 600 (time after initial fixedWindowStart + // to set rolling window at 100% of previous fixed window) - // to set initial fixedWindowStart - await setTokenCountInClient(client, user4, 0, 0, timestamp); + // to set initial fixedWindowStart + await setTokenCountInClient(client, user4, 0, 0, timestamp); - const initRequest = 8; + const initRequest = 8; - // large request at very end of first fixed window - await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); + // large request at very end of first fixed window + await limiter.processRequest(user4, timestamp + WINDOW_SIZE - 1, initRequest); - // 11 + 8 * .01 = 11, above capacity (request should be blocked) - const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 11); - expect(result.tokens).toBe(10); - expect(result.success).toBe(false); + // 11 + 8 * .01 = 11, above capacity (request should be blocked) + const result = await limiter.processRequest(user4, timestamp + WINDOW_SIZE, 11); + expect(result.tokens).toBe(2); + expect(result.success).toBe(false); - // currentTokens (in current fixed window): 0 - // previousTokens (in previous fixed window): 8 - const count1 = await getWindowFromClient(client, user4); - expect(count1.currentTokens).toBe(0); - expect(count1.previousTokens).toBe(initRequest); + // currentTokens (in current fixed window): 0 + // previousTokens (in previous fixed window): 8 + const count1 = await getWindowFromClient(client, user4); + expect(count1.currentTokens).toBe(0); + expect(count1.previousTokens).toBe(initRequest); + }); }); }); @@ -465,7 +473,7 @@ describe('Test TokenBucket Rate Limiter', () => { await ( await limiter.processRequest(user1, timestamp + WINDOW_SIZE, 4) ).tokens - ).toBe(2); + ).toBe(1); // currentTokens (in current fixed window): 0 // previousTokens (in previous fixed window): 8 const count = await getWindowFromClient(client, user1); From 912108fca4a2acced41bc9849af8bbbd48021a51 Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Thu, 21 Jul 2022 17:49:40 -0700 Subject: [PATCH 13/16] added ts-ignore --- src/middleware/rateLimiterSetup.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/middleware/rateLimiterSetup.ts b/src/middleware/rateLimiterSetup.ts index be7f57d..dc7f217 100644 --- a/src/middleware/rateLimiterSetup.ts +++ b/src/middleware/rateLimiterSetup.ts @@ -1,5 +1,6 @@ +//@ts-ignore import Redis from 'ioredis'; -import { RateLimiterOptions, RateLimiterSelection } from '../@types/rateLimit'; +import { RateLimiterOptions, RateLimiterSelection, TokenBucketOptions } from '../@types/rateLimit'; import SlidingWindowCounter from '../rateLimiters/slidingWindowCounter'; import TokenBucket from '../rateLimiters/tokenBucket'; From f6cea9abbf37fa06fcb5a10d0af9776be699c500 Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Thu, 21 Jul 2022 17:51:10 -0700 Subject: [PATCH 14/16] fixed ts-ignore --- src/middleware/rateLimiterSetup.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/middleware/rateLimiterSetup.ts b/src/middleware/rateLimiterSetup.ts index dc7f217..b1b9925 100644 --- a/src/middleware/rateLimiterSetup.ts +++ b/src/middleware/rateLimiterSetup.ts @@ -1,4 +1,3 @@ -//@ts-ignore import Redis from 'ioredis'; import { RateLimiterOptions, RateLimiterSelection, TokenBucketOptions } from '../@types/rateLimit'; import SlidingWindowCounter from '../rateLimiters/slidingWindowCounter'; @@ -21,6 +20,7 @@ export default function setupRateLimiter( switch (selection) { case 'TOKEN_BUCKET': // todo validate options + //@ts-ignore return new TokenBucket(options.bucketSize, options.refillRate, client); break; case 'LEAKY_BUCKET': @@ -33,6 +33,7 @@ export default function setupRateLimiter( throw new Error('Sliding Window Log has not be implemented.'); break; case 'SLIDING_WINDOW_COUNTER': + //@ts-ignore return new SlidingWindowCounter(options.windowSize, options.capacity, client); break; default: From 101ab24a92962c7137fec0daf838045b1f07860f Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Thu, 21 Jul 2022 17:53:28 -0700 Subject: [PATCH 15/16] added lint ignores --- src/middleware/rateLimiterSetup.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/middleware/rateLimiterSetup.ts b/src/middleware/rateLimiterSetup.ts index b1b9925..d7a3fb3 100644 --- a/src/middleware/rateLimiterSetup.ts +++ b/src/middleware/rateLimiterSetup.ts @@ -20,7 +20,8 @@ export default function setupRateLimiter( switch (selection) { case 'TOKEN_BUCKET': // todo validate options - //@ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore return new TokenBucket(options.bucketSize, options.refillRate, client); break; case 'LEAKY_BUCKET': @@ -33,7 +34,8 @@ export default function setupRateLimiter( throw new Error('Sliding Window Log has not be implemented.'); break; case 'SLIDING_WINDOW_COUNTER': - //@ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore return new SlidingWindowCounter(options.windowSize, options.capacity, client); break; default: From 44a52f8b3e5c735194a8dd4dcfa4a265f35de31a Mon Sep 17 00:00:00 2001 From: Jon Dewey Date: Mon, 25 Jul 2022 16:04:20 -0700 Subject: [PATCH 16/16] simplified skipped window functionality --- src/rateLimiters/slidingWindowCounter.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/rateLimiters/slidingWindowCounter.ts b/src/rateLimiters/slidingWindowCounter.ts index 62f8f3a..1dc51c6 100644 --- a/src/rateLimiters/slidingWindowCounter.ts +++ b/src/rateLimiters/slidingWindowCounter.ts @@ -115,14 +115,10 @@ class SlidingWindowCounter implements RateLimiter { if (timestamp >= window.fixedWindowStart + this.windowSize) { // if more than one window was skipped if (timestamp >= window.fixedWindowStart + this.windowSize * 2) { - // calculates how many windows may have been skipped since last request - const windowsSkipped = Math.floor( - (timestamp - window.fixedWindowStart) / this.windowSize - ); + // if one or more windows was skipped, reset new window to be at current timestamp updatedUserWindow.previousTokens = 0; updatedUserWindow.currentTokens = 0; - updatedUserWindow.fixedWindowStart = - window.fixedWindowStart + this.windowSize * windowsSkipped; + updatedUserWindow.fixedWindowStart = timestamp; } else { updatedUserWindow.previousTokens = updatedUserWindow.currentTokens; updatedUserWindow.currentTokens = 0;