UNPKG

zero-backpressure-fixed-window-promise-rate-limiter

Version:

A modern Promise rate-limiter for Node.js projects, implementing a fixed-window throttling policy. It restricts the number of tasks that can *start* execution within distinct, non-overlapping time windows. The API provides backpressure control by signalin

599 lines (514 loc) 31.5 kB
/** * Copyright 2024 Ori Cohen https://github.com/ori88c * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { FixedWindowRateLimiter } from './zero-backpressure-fixed-window-promise-rate-limiter'; type PromiseResolveCallbackType = (value?: unknown) => void; type PromiseRejectCallbackType = (reason?: Error) => void; interface CustomTaskError extends Error { taskID: number; } /** * resolveFast * * The one-and-only purpose of this function, is triggerring an event-loop iteration. * It is relevant whenever a test needs to simulate tasks from the Node.js' micro-tasks queue. */ const resolveFast = async () => { expect(14).toBeGreaterThan(3); }; const MOCK_WINDOW_DURATION_MS = 15 * 1000; // Can be long, as we use Jest's fake timers. const MOCK_MAX_STARTS_PER_WINDOW = 37; const createTestLimiter = () => new FixedWindowRateLimiter<void>(MOCK_WINDOW_DURATION_MS, MOCK_MAX_STARTS_PER_WINDOW); describe('FixedWindowRateLimiter tests', () => { let setTimeoutSpy: jest.SpyInstance; beforeEach(() => { jest.useFakeTimers(); setTimeoutSpy = jest.spyOn(global, 'setTimeout'); }); afterEach(() => { jest.restoreAllMocks(); jest.useRealTimers(); }); const triggerEndingOfCurrentWindow = (): void => { // The 1st task of each window sets a new setTimeout timer. The timer's callback is executed // once the window ends, updating the rate-limiter's internal state. jest.runOnlyPendingTimers(); }; describe('Happy path tests', () => { test('validate initial state following instantiation', async () => { const rateLimiter = createTestLimiter(); expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS); expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.isCurrentWindowAvailable).toBe(true); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(0); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(0); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); expect(setTimeoutSpy).toHaveBeenCalledTimes(0); }); test('when the window capacity is exhausted, the Rate Limiter should stop executing tasks utill a new window opens up', async () => { const rateLimiter = createTestLimiter(); let windowsCounter = 1; const finishTaskCallbacks: PromiseResolveCallbackType[] = []; const createTask = () => new Promise<void>(res => finishTaskCallbacks.push(res)); for (let ithTask = 1; ithTask <= MOCK_MAX_STARTS_PER_WINDOW; ++ithTask) { await rateLimiter.startExecution(createTask); expect(setTimeoutSpy).toHaveBeenCalledTimes(windowsCounter); // setTimeout is triggered by the 1st window task. expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS); expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.isCurrentWindowAvailable).toBe(ithTask < MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(ithTask); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(ithTask); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); } // Now, we push an excessive task which cannot be started during the current window. const outOfFirstWindowStartExecutionPromise = rateLimiter.startExecution(createTask); await Promise.race([outOfFirstWindowStartExecutionPromise, resolveFast()]); expect(rateLimiter.isCurrentWindowAvailable).toBe(false); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); // Now, we resolve all the 1st window tasks. The excessive task still cannot be executed, // as the fixed time-window did not end. for (let ithTask = 1; ithTask <= MOCK_MAX_STARTS_PER_WINDOW; ++ithTask) { finishTaskCallbacks[ithTask - 1](); await Promise.race([outOfFirstWindowStartExecutionPromise, resolveFast()]); expect(setTimeoutSpy).toHaveBeenCalledTimes(windowsCounter); expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS); expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.isCurrentWindowAvailable).toBe(false); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(MOCK_MAX_STARTS_PER_WINDOW - ithTask); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); } // Now, we simulate the ending of current window, allowing the pending task to begin. triggerEndingOfCurrentWindow(); ++windowsCounter; await outOfFirstWindowStartExecutionPromise; expect(finishTaskCallbacks.length).toBe(MOCK_MAX_STARTS_PER_WINDOW + 1); expect(setTimeoutSpy).toHaveBeenCalledTimes(windowsCounter); expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS); expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.isCurrentWindowAvailable).toBe(true); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(1); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(1); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); // Finish the out-of-first-window task. finishTaskCallbacks.pop()(); await rateLimiter.waitForAllExecutingTasksToComplete(); expect(rateLimiter.isCurrentWindowAvailable).toBe(true); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(0); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(1); }); test( 'waitForAllExecutingTasksToComplete should resolve once all executing tasks have completed: ' + 'setup with insufficient initial slots, triggering dynamic slot allocation. ' + 'Tasks are resolved in FIFO order in this test', async () => { // This test deliberately creates backpressure by simulating a burst of tasks // that spans `amountOfWindows` time windows. As a result, each window (except the final one) // is unable to process all the pending tasks. const amountOfWindows = 12; const amountOfTasks = amountOfWindows * MOCK_MAX_STARTS_PER_WINDOW; // Each window is fully utilized. const rateLimiter = createTestLimiter(); // From the Rate Limiter's perspective, a task is considered complete upon either // success or failure (i.e., when the Promise is resolved or rejected). // To simulate real-world scenarios, this test includes tasks that both succeed and fail. const taskCompletionCallbacks: (PromiseResolveCallbackType | PromiseRejectCallbackType)[] = []; const createResolvingTask = () => new Promise<void>(res => { taskCompletionCallbacks.push(res); // This promise will remain unsettled until we manually invoke the 'res' callback, // simulating an ongoing task that is about to complete successfully. }); const createRejectingTask = () => new Promise<void>((_, rej) => { taskCompletionCallbacks.push(rej); // This promise will remain unsettled until we manually invoke the 'rej' callback, // simulating an ongoing task that is about to fail. }); const waitForCompletionPromises: Promise<void>[] = []; for (let ithTask = 1; ithTask <= amountOfTasks; ++ithTask) { const shouldTaskSucceed = ithTask % 2 === 0; // Odd-numbered tasks fail, while even-numbered tasks succeed. waitForCompletionPromises.push( // Tasks will *start* execution in the order in which they were registered. rateLimiter.waitForCompletion(shouldTaskSucceed ? createResolvingTask : createRejectingTask) ); // Trigger the event loop. // This may activate the rate limiter's dynamic slot allocation for this task, // if the window's capacity hasn't been fully utilized yet. // Most tasks should receive a new slot, as the rate limiter initially allocates // slightly more slots than the window's capacity. However, in this test, there are // `amountOfWindows` windows, which is significantly higher. await Promise.race([ waitForCompletionPromises[waitForCompletionPromises.length - 1], resolveFast() ]); } // Trigger the end of all windows, while none of the tasks have settled yet. // We expect the Rate Limiter to retain references to all still-executing tasks, // including those from earlier windows that have already ended. for (let ithWindow = 1; ithWindow <= amountOfWindows; ++ithWindow) { expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe( ithWindow * MOCK_MAX_STARTS_PER_WINDOW // Reminder: tasks from previous windows are still executing. ); expect(rateLimiter.isCurrentWindowAvailable).toBe(false); // End the current window and trigger the event loop, allowing the next batch // of the highest-priority tasks (first in Node.js's microtasks queue) to start // execution. triggerEndingOfCurrentWindow(); await Promise.race([waitForCompletionPromises[0], resolveFast()]); } let allTasksCompleted = false; const waitForAllExecutingTasksToComplete: Promise<void> = (async () => { await rateLimiter.waitForAllExecutingTasksToComplete(); allTasksCompleted = true; })(); await Promise.race([waitForAllExecutingTasksToComplete, resolveFast()]); expect(allTasksCompleted).toBe(false); // Complete all tasks one by one, in FIFO order. let expectedAmountOfCurrentlyExecutingTasks = amountOfTasks; for (let ithTask = 1; ithTask <= amountOfTasks; ++ithTask) { let thrownError: Error; const shouldTaskSucceed = ithTask % 2 === 0; if (shouldTaskSucceed) { taskCompletionCallbacks[ithTask -1](); // Invoking the task's Promise-resolve callback. } else { thrownError = new Error(`mock error message: ${ithTask}`); taskCompletionCallbacks[ithTask - 1](thrownError); // Invoking the task's Promise-reject callback. } --expectedAmountOfCurrentlyExecutingTasks; // Trigger the event loop, we expect the current task promise to be settled. if (shouldTaskSucceed) { await waitForCompletionPromises[ithTask - 1]; } else { // The current task rejects. try { await waitForCompletionPromises[ithTask - 1]; expect(true).toBe(false); // The flow should not reach this point. } catch (err) { expect(err.message).toEqual(thrownError.message); } } // Trigger the event loop. if (ithTask === amountOfTasks) { await waitForAllExecutingTasksToComplete; // We have just completed the last task. } else { await Promise.race([waitForAllExecutingTasksToComplete, resolveFast()]); } expect(allTasksCompleted).toBe(ithTask === amountOfTasks); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks); // Currently we are in the (amountOfWindows +1)th window. We don't add any // tasks to it, so its metrics are expected to remain unchanged. expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(0); expect(rateLimiter.isCurrentWindowAvailable).toBe(true); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); } expect(allTasksCompleted).toBe(true); }); test( 'waitForAllExecutingTasksToComplete should resolve once all executing tasks have completed: ' + 'setup with insufficient initial slots, triggering dynamic slot allocation. ' + 'Tasks are resolved in FILO order in this test', async () => { // FILO order for task completion times is unlikely in real life, but it’s a good edge case to test. // It ensures the rate limiter can maintain a reference to an old task, even if its execution time exceeds // all others. // This test deliberately creates backpressure by simulating a burst of tasks // that spans `amountOfWindows` time windows. As a result, each window (except the final one) // is unable to process all the pending tasks. const amountOfWindows = 10; const amountOfTasks = amountOfWindows * MOCK_MAX_STARTS_PER_WINDOW; // Each window is fully utilized. const rateLimiter = createTestLimiter(); // From the Rate Limiter's perspective, a task is considered complete upon either // success or failure (i.e., when the Promise is resolved or rejected). // To simulate real-world scenarios, this test includes tasks that both succeed and fail. const taskCompletionCallbacks: (PromiseResolveCallbackType | PromiseRejectCallbackType)[] = []; const createResolvingTask = () => new Promise<void>(res => { taskCompletionCallbacks.push(res); // This promise will remain unsettled until we manually invoke the 'res' callback, // simulating an ongoing task that is about to complete successfully. }); const createRejectingTask = () => new Promise<void>((_, rej) => { taskCompletionCallbacks.push(rej); // This promise will remain unsettled until we manually invoke the 'rej' callback, // simulating an ongoing task that is about to fail. }); const waitForCompletionPromises: Promise<void>[] = []; for (let ithTask = 1; ithTask <= amountOfTasks; ++ithTask) { const shouldTaskSucceed = ithTask % 2 === 0; // Odd-numbered tasks fail, while even-numbered tasks succeed. waitForCompletionPromises.push( // Tasks will *start* execution in the order in which they were registered. rateLimiter.waitForCompletion(shouldTaskSucceed ? createResolvingTask : createRejectingTask) ); // Trigger the event loop. // This may activate the rate limiter's dynamic slot allocation for this task, // if the window's capacity hasn't been fully utilized yet. // Most tasks should receive a new slot, as the rate limiter initially allocates // slightly more slots than the window's capacity. However, in this test, there are // `amountOfWindows` windows, which is significantly higher. await Promise.race([ waitForCompletionPromises[waitForCompletionPromises.length - 1], resolveFast() ]); } // Trigger the end of all windows, while none of the tasks have settled yet. // We expect the Rate Limiter to retain references to all still-executing tasks, // including those from earlier windows that have already ended. for (let ithWindow = 1; ithWindow <= amountOfWindows; ++ithWindow) { expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe( ithWindow * MOCK_MAX_STARTS_PER_WINDOW // Reminder: tasks from previous windows are still executing. ); expect(rateLimiter.isCurrentWindowAvailable).toBe(false); // End the current window and trigger the event loop, allowing the next batch // of the highest-priority tasks (first in Node.js's microtasks queue) to start // execution. triggerEndingOfCurrentWindow(); await Promise.race([waitForCompletionPromises[0], resolveFast()]); } let allTasksCompleted = false; const waitForAllExecutingTasksToComplete: Promise<void> = (async () => { await rateLimiter.waitForAllExecutingTasksToComplete(); allTasksCompleted = true; })(); await Promise.race([waitForAllExecutingTasksToComplete, resolveFast()]); expect(allTasksCompleted).toBe(false); // Complete all tasks one by one, in FILO order. let expectedAmountOfCurrentlyExecutingTasks = amountOfTasks; for (let ithTask = amountOfTasks; ithTask >= 1; --ithTask) { let thrownError: Error; const shouldTaskSucceed = ithTask % 2 === 0; if (shouldTaskSucceed) { taskCompletionCallbacks.pop()(); // Invoking the task's Promise-resolve callback. } else { thrownError = new Error(`mock error message: ${ithTask}`); taskCompletionCallbacks.pop()(thrownError); // Invoking the task's Promise-reject callback. } --expectedAmountOfCurrentlyExecutingTasks; // Trigger the event loop, we expect the current task promise to be settled. if (shouldTaskSucceed) { await waitForCompletionPromises.pop(); } else { // The current task rejects. try { await waitForCompletionPromises.pop(); expect(true).toBe(false); // The flow should not reach this point. } catch (err) { expect(err.message).toEqual(thrownError.message); } } // Trigger the event loop. if (ithTask === 1) { await waitForAllExecutingTasksToComplete; // We have just completed the last task, the oldest one. } else { await Promise.race([waitForAllExecutingTasksToComplete, resolveFast()]); } expect(allTasksCompleted).toBe(ithTask === 1); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks); // Currently we are in the (amountOfWindows +1)th window. We don't add any // tasks to it, so its metrics are expected to remain unchanged. expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(0); expect(rateLimiter.isCurrentWindowAvailable).toBe(true); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); } expect(allTasksCompleted).toBe(true); }); test('startExecution: when backpressure is induced, each window should honor its capacity', async () => { const rateLimiter = createTestLimiter(); const totalAmountOfWindows = 9; const amountOfLastWindowTasks = Math.floor(3 * MOCK_MAX_STARTS_PER_WINDOW / 4); // The last window won't utilize all its capacity. const totalAmountOfTasks = (totalAmountOfWindows - 1) * MOCK_MAX_STARTS_PER_WINDOW + amountOfLastWindowTasks; const startExecutionPromises = new Array<Promise<void>>(totalAmountOfTasks).fill(undefined); const taskCompletionCallbacks = new Array<PromiseResolveCallbackType>(totalAmountOfTasks).fill(undefined); // We push all tasks at once, inducing backpressure deliberately. // Note: this is not a mindful / wise use of the rate-limiter's capabilities, as the `startExecution` // method helps to avoid backpressure by promoting a just-in-time approach. // However, the rate-limiter guarantees validity under any settings, including under backpressure. for (let ithTask = 1; ithTask <= totalAmountOfTasks; ++ithTask) { // We create unresolved promises, simulating an async work in progress. // They will be resolved later, once we want to simulate completion of the async work. const taskIndex = ithTask - 1; const createTask = () => new Promise<void>(res => taskCompletionCallbacks[taskIndex] = res); startExecutionPromises[taskIndex] = rateLimiter.startExecution(createTask); // Trigger the event loop. await Promise.race([ startExecutionPromises[taskIndex], resolveFast() ]); expect(setTimeoutSpy).toHaveBeenCalledTimes(1); // setTimeout is triggered by the 1st window task. expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS); expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.isCurrentWindowAvailable).toBe(ithTask < MOCK_MAX_STARTS_PER_WINDOW); // Only 1st window tasks will begin execution. const amountOfAlreadyAddedFirstWindowTasks = Math.min(ithTask, MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(amountOfAlreadyAddedFirstWindowTasks); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfAlreadyAddedFirstWindowTasks); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); } // Each main iteration begins when the current window (denoted by windowsCounter) is open, thus we // expect the rate-limiter to trigger (begin) all its tasks. // At the end of each main loop, we trigger the next window by advancing the system clock to the timestamp // when the current window ends, using fake timers. for (let ithWindow = 1; ithWindow <= totalAmountOfWindows; ++ithWindow) { // The last window is not fully occupied, while all the others are. const isLastWindow = ithWindow === totalAmountOfWindows; const expectedAmountOfCurrentlyExecutingTasks = isLastWindow ? totalAmountOfTasks : (ithWindow * MOCK_MAX_STARTS_PER_WINDOW); const amountOfCurrentWindowTasks = isLastWindow ? amountOfLastWindowTasks : MOCK_MAX_STARTS_PER_WINDOW; let currTaskIndex = (ithWindow - 1) * MOCK_MAX_STARTS_PER_WINDOW; for (let ithCurrWindowTask = 1; ithCurrWindowTask <= amountOfCurrentWindowTasks; ++ithCurrWindowTask, ++currTaskIndex) { await startExecutionPromises[currTaskIndex]; expect(setTimeoutSpy).toHaveBeenCalledTimes(ithWindow); expect(rateLimiter.isCurrentWindowAvailable).toBe(isLastWindow); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfCurrentWindowTasks); } if (!isLastWindow) { triggerEndingOfCurrentWindow(); // Trigger the event loop. All the next-window tasks will begin execution. const nextWindowLastTaskIndex = Math.min( totalAmountOfTasks - 1, (ithWindow + 1) * MOCK_MAX_STARTS_PER_WINDOW - 1 ); await startExecutionPromises[nextWindowLastTaskIndex]; } } // Now, we finish tasks one by one. The order of completion does not matter for validating metrics. // We will use a FILO order, meaning a task that started later will be finished sooner. let expectedAmountOfCurrentlyExecutingTasks = totalAmountOfTasks; do { const completeCurrentTask: PromiseResolveCallbackType = taskCompletionCallbacks.pop(); completeCurrentTask(); // Trigger the event loop, to update the rate-limiter's internal state. await resolveFast(); --expectedAmountOfCurrentlyExecutingTasks; expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks); // We are still within the last window. expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfLastWindowTasks); expect(setTimeoutSpy).toHaveBeenCalledTimes(totalAmountOfWindows); expect(rateLimiter.isCurrentWindowAvailable).toBe(true); // Last window is not fully occupied. } while (expectedAmountOfCurrentlyExecutingTasks > 0); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); }); test('waitForCompletion: when backpressure is induced, each window should honor its capacity', async () => { const rateLimiter = createTestLimiter(); const totalAmountOfWindows = 7; const amountOfLastWindowTasks = Math.floor(7 * MOCK_MAX_STARTS_PER_WINDOW / 9); // The last window won't utilize all its capacity. const totalAmountOfTasks = (totalAmountOfWindows - 1) * MOCK_MAX_STARTS_PER_WINDOW + amountOfLastWindowTasks; const waitForCompletionPromises = new Array<Promise<void>>(totalAmountOfTasks).fill(undefined); const taskCompletionCallbacks = new Array<PromiseResolveCallbackType>(totalAmountOfTasks).fill(undefined); // We push all tasks at once, inducing backpressure deliberately. // Such a scenario can be unavoidable, for example if there's a spike in requests // to a specific route handler, which uses a rate-limiter to comply with a third-party // API that has throttling limits. for (let ithTask = 1; ithTask <= totalAmountOfTasks; ++ithTask) { // We create unresolved promises, simulating an async work in progress. // They will be resolved later, once we want to simulate completion of the async work. const taskIndex = ithTask - 1; const createTask = () => new Promise<void>( res => taskCompletionCallbacks[taskIndex] = res ); waitForCompletionPromises[taskIndex] = rateLimiter.waitForCompletion(createTask);; // Trigger the event loop. await Promise.race([ waitForCompletionPromises[taskIndex], resolveFast() ]); expect(setTimeoutSpy).toHaveBeenCalledTimes(1); // setTimeout is triggered by the 1st window task. expect(rateLimiter.windowDurationMs).toBe(MOCK_WINDOW_DURATION_MS); expect(rateLimiter.maxStartsPerWindow).toBe(MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.isCurrentWindowAvailable).toBe(ithTask < MOCK_MAX_STARTS_PER_WINDOW); // Only 1st window tasks will begin execution. const amountOfAlreadyAddedFirstWindowTasks = Math.min(ithTask, MOCK_MAX_STARTS_PER_WINDOW); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(amountOfAlreadyAddedFirstWindowTasks); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfAlreadyAddedFirstWindowTasks); expect(rateLimiter.amountOfUncaughtErrors).toBe(0); } // Each main iteration begins when the current window (denoted by windowsCounter) is open, thus we // expect the rate-limiter to trigger (begin) all its tasks. // At the end of each main loop, we trigger the next window by advancing the system clock to the timestamp // when the current window ends, using fake timers. for (let currentWindowNo = 1; currentWindowNo <= totalAmountOfWindows; ++currentWindowNo) { // The last window is not fully occupied, while all the others are. const isLastWindow = currentWindowNo === totalAmountOfWindows; const expectedAmountOfCurrentlyExecutingTasks = isLastWindow ? totalAmountOfTasks : (currentWindowNo * MOCK_MAX_STARTS_PER_WINDOW); const amountOfCurrentWindowTasks = isLastWindow ? amountOfLastWindowTasks : MOCK_MAX_STARTS_PER_WINDOW; expect(setTimeoutSpy).toHaveBeenCalledTimes(currentWindowNo); expect(rateLimiter.isCurrentWindowAvailable).toBe(isLastWindow); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks); expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfCurrentWindowTasks); if (!isLastWindow) { triggerEndingOfCurrentWindow(); // Trigger the event loop. All next-window tasks will begin execution. await Promise.race([ waitForCompletionPromises, resolveFast() ]); } } // Now, we finish tasks one by one. The order of completion does not matter for validating metrics. // In this test, tasks will be completed in a FIFO order. let expectedAmountOfCurrentlyExecutingTasks = totalAmountOfTasks; for (let ithTask = 1; ithTask <= totalAmountOfTasks; ++ithTask) { const completeOldestExecutingTask: PromiseResolveCallbackType = taskCompletionCallbacks[ithTask - 1]; completeOldestExecutingTask(); await waitForCompletionPromises[ithTask - 1]; // This wait-for-completion promise corresponds the just-completed task. --expectedAmountOfCurrentlyExecutingTasks; expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(expectedAmountOfCurrentlyExecutingTasks); // We are still within the last window. expect(rateLimiter.amountOfTasksInitiatedDuringCurrentWindow).toBe(amountOfLastWindowTasks); expect(setTimeoutSpy).toHaveBeenCalledTimes(totalAmountOfWindows); expect(rateLimiter.isCurrentWindowAvailable).toBe(true); // Last window is not fully occupied. } expect(rateLimiter.amountOfUncaughtErrors).toBe(0); }); }); describe('Negative path tests', () => { test('should throw if window duration is less than 15ms or non-natural number', async () => { const invalidWindowDurations = [-16, -0.29, 0.0001, 10, 14.9999, 15.54, 16.8989, 9348.4433]; for (const windowDurationMs of invalidWindowDurations) { expect(() => new FixedWindowRateLimiter(windowDurationMs, MOCK_MAX_STARTS_PER_WINDOW)).toThrow(); } }); test('should throw if max starts per window is a non-natural number', async () => { const invalidMaxStartsPerWindow = [-16, -0.29, 0.0001, 10.0000001, 14.9999, 15.54, 16.8989, 9348.4433]; for (const maxStartsPerWindow of invalidMaxStartsPerWindow) { expect(() => new FixedWindowRateLimiter(MOCK_WINDOW_DURATION_MS, maxStartsPerWindow)).toThrow(); } }); test('should capture uncaught errors from background tasks triggered by startExecution', async () => { // In this test, we simulate a single window in which all tasks throw an error. const rateLimiter = createTestLimiter(); const amountOfTasks = MOCK_MAX_STARTS_PER_WINDOW; const expectedTaskErrors: CustomTaskError[] = []; const createError = (taskID: number): CustomTaskError => ({ name: "CustomTaskError", message: `Task no. ${taskID} has failed`, taskID }); for (let ithTask = 1; ithTask <= amountOfTasks; ++ithTask) { expectedTaskErrors.push(createError(ithTask)); // We deliberately create a new error instance with the exact same fields to validate deep equality. await rateLimiter.startExecution(async () => { throw createError(ithTask); }); } await rateLimiter.waitForAllExecutingTasksToComplete(); expect(rateLimiter.amountOfCurrentlyExecutingTasks).toBe(0); expect(rateLimiter.amountOfUncaughtErrors).toBe(amountOfTasks); expect(rateLimiter.extractUncaughtErrors()).toEqual(expectedTaskErrors); // Following extraction, the rate-limiter no longer holds the error references. expect(rateLimiter.amountOfUncaughtErrors).toBe(0); }); }); });