UNPKG

throttled-queue

Version:

Throttles arbitrary code to execute a maximum number of times per interval. Best for making throttled API requests.

483 lines (454 loc) 16.4 kB
import { throttledQueue, DEFAULT_WAIT, DEFAULT_RETRY_LIMIT, RetryError } from './throttledQueue'; import { describe, expect, it } from 'vitest'; describe.concurrent('throttled-queue', () => { it('should queue all fns', async () => { const maxPerInterval = 1; const interval = 200; const throttle = throttledQueue({ maxPerInterval, interval }); let numRequests = 0; const requestLimit = 100; await Promise.all( Array.from({ length: requestLimit }).map( () => throttle(() => { numRequests++; }), ), ); expect(numRequests).toEqual(requestLimit); }); it('should queue the fn and honor the interval', async () => { const maxPerInterval = 1; const interval = 500; const throttle = throttledQueue({ maxPerInterval, interval }); const requestLimit = 100; let lastIntervalStart = 0; let numRequests = 0; await Promise.all( Array.from({ length: requestLimit }).map( (_, x) => throttle(({ intervalStart }) => { numRequests++; if (x) { if ((intervalStart - lastIntervalStart) < interval) { throw new Error(`Interval not honored: ${intervalStart - lastIntervalStart} vs. ${interval}`); } lastIntervalStart = intervalStart; } else { lastIntervalStart = intervalStart; } }), ), ); expect(numRequests).toEqual(requestLimit); }); it('should queue the fn and honor the interval with multiple requests per interval', async () => { const maxPerInterval = 5; const interval = 1000; const throttle = throttledQueue({ maxPerInterval, interval }); const requestLimit = 100; let lastIntervalStart = 0; let numRequests = 0; let inInterval = 0; await Promise.all( Array.from({ length: requestLimit }).map( (_, x) => throttle(({ intervalStart }) => { numRequests++; if (x) { if ((intervalStart - lastIntervalStart) < interval) { inInterval++; } else { if (inInterval > maxPerInterval) { throw new Error( `Got ${inInterval} requests per interval, expected ${maxPerInterval}. ${intervalStart - lastIntervalStart} vs. ${interval}.`, ); } lastIntervalStart = intervalStart; inInterval = 0; } } else { lastIntervalStart = intervalStart; } }), ), ); expect(numRequests).toEqual(requestLimit); }); it('should queue the fn and honor the interval with multiple evenly spaced requests per interval', async () => { const maxPerInterval = 5; const interval = 1000; const throttle = throttledQueue({ maxPerInterval, interval, evenlySpaced: true }); const requestLimit = 100; let lastIntervalStart = 0; let numRequests = 0; let inInterval = 0; await Promise.all( Array.from({ length: requestLimit }).map( (_, x) => throttle(({ intervalStart }) => { numRequests++; if (x) { if ((intervalStart - lastIntervalStart) < interval) { inInterval++; } else { if (inInterval > maxPerInterval) { throw new Error( `Got ${inInterval} requests per interval, expected ${maxPerInterval}. ${intervalStart - lastIntervalStart} vs. ${interval}.`, ); } lastIntervalStart = intervalStart; inInterval = 0; } } else { lastIntervalStart = intervalStart; } }), ), ); expect(numRequests).toEqual(requestLimit); }); it('returns a promise that resolves when the fn executes', async () => { const maxPerInterval = 3; const interval = 1000; const throttle = throttledQueue({ maxPerInterval, interval }); const numbers = [1, 2, 3, 4, 5]; const results = await Promise.all( numbers.map( (number) => throttle<number>( () => new Promise( (resolve) => setTimeout(() => resolve(number), 500), ), ), ), ); expect(numbers).toEqual(results); }); it('allows for unbounded queues', async () => { const throttle = throttledQueue(); const numbers = [1, 2, 3, 4, 5]; const results = await Promise.all( numbers.map( (number) => throttle<number>( () => new Promise( (resolve) => setTimeout(() => resolve(number), 500), ), ), ), ); expect(numbers).toEqual(results); }); it('treats queues that do not specify an interval as unbounded', async () => { const throttle = throttledQueue({ maxPerInterval: 1 }); const numbers = [1, 2, 3, 4, 5]; const results = await Promise.all( numbers.map( (number) => throttle<number>( () => new Promise( (resolve) => setTimeout(() => resolve(number), 500), ), ), ), ); expect(numbers).toEqual(results); }); describe.concurrent('retries', () => { it('enables a single execution to be retried after the default wait time', async () => { const throttle = throttledQueue(); const numbers = [1, 2, 3, 4, 5]; const retriedNumbers = new Set<number>(); const executionTime = DEFAULT_WAIT / 2; const now = Date.now(); const results = await Promise.all( numbers.map( (number) => throttle( () => { if (!retriedNumbers.has(number)) { retriedNumbers.add(number); throw new RetryError(); } return new Promise<number>( (resolve) => setTimeout(() => resolve(number), executionTime), ); }, ), ), ); const totalExecutionTime = Date.now() - now; expect(totalExecutionTime).toBeGreaterThanOrEqual(executionTime + DEFAULT_WAIT); expect(numbers).toEqual(results); expect(numbers).toEqual(Array.from(retriedNumbers)); }); it('enables a single execution to be retried with the supplied wait time', async () => { const throttle = throttledQueue(); const numbers = [1, 2, 3, 4, 5]; const retriedNumbers = new Set<number>(); const waitTime = DEFAULT_WAIT / 2; const executionTime = waitTime / 2; const now = Date.now(); const results = await Promise.all( numbers.map( (number) => throttle( () => { if (!retriedNumbers.has(number)) { retriedNumbers.add(number); throw new RetryError({ retryAfter: waitTime }); } return new Promise<number>( (resolve) => setTimeout(() => resolve(number), executionTime), ); }, ), ), ); const totalExecutionTime = Date.now() - now; expect(totalExecutionTime).toBeGreaterThanOrEqual(executionTime + waitTime); expect(numbers).toEqual(results); expect(numbers).toEqual(Array.from(retriedNumbers)); }); it('enables a single execution to be retried using interval as wait time when supplied', async () => { const interval = 750; const throttle = throttledQueue({ interval }); const numbers = [1, 2, 3, 4, 5]; const retriedNumbers = new Set<number>(); const executionTime = interval / 2; const now = Date.now(); const results = await Promise.all( numbers.map( (number) => throttle( () => { if (!retriedNumbers.has(number)) { retriedNumbers.add(number); throw new RetryError(); } return new Promise( (resolve) => setTimeout(() => resolve(number), executionTime), ); }, ), ), ); const totalExecutionTime = Date.now() - now; expect(totalExecutionTime).toBeGreaterThanOrEqual(executionTime + interval); expect(numbers).toEqual(results); expect(numbers).toEqual(Array.from(retriedNumbers)); }); it('prevents a single execution from being retried more than the default retry limit', async () => { const throttle = throttledQueue(); const numbers = [1, 2, 3, 4, 5]; const waitTime = DEFAULT_WAIT / 2; const numbersToTries = new Map<number, number>(); const results = await Promise.allSettled( numbers.map( (number) => throttle( () => { numbersToTries.set(number, (numbersToTries.get(number) ?? 0) + 1); throw new RetryError({ retryAfter: waitTime }); }, ), ), ); expect(results).toEqual( Array.from({ length: numbers.length }).map( () => ({ status: 'rejected', reason: new RetryError({ retryAfter: waitTime }) }), ), ); expect(numbersToTries).toEqual(new Map( numbers.map( (number) => ([number, DEFAULT_RETRY_LIMIT + 1]), ), )); }); it('prevents a single execution from being retried more than the supplied max retries', async () => { const maxRetries = 1; const throttle = throttledQueue({ maxRetries }); const numbers = [1, 2, 3, 4, 5]; const numbersToTries = new Map<number, number>(); const results = await Promise.allSettled( numbers.map( (number) => throttle( () => { numbersToTries.set(number, (numbersToTries.get(number) ?? 0) + 1); throw new RetryError(); }, ), ), ); expect(results).toEqual( Array.from({ length: numbers.length }).map( () => ({ status: 'rejected', reason: new RetryError() }), ), ); expect(numbersToTries).toEqual(new Map( numbers.map( (number) => ([number, maxRetries + 1]), ), )); }); it('enables the queue to pause executions not already in-flight until the default wait time', async () => { const throttle = throttledQueue(); const executionTime = DEFAULT_WAIT / 2; const now = Date.now(); const promises: Promise<number>[] = []; promises.push( throttle( async ({ state }) => { if (!state.retried) { state.retried = true; throw new RetryError({ pauseQueue: true }); } await new Promise((resolve) => setTimeout(resolve, executionTime)); return 1; }, { retried: false }, ), ); await new Promise<void>( (resolve) => { setTimeout( () => { promises.push( throttle( async ({ state }) => { if (!state.retried) { state.retried = true; throw new RetryError({ pauseQueue: true }); } await new Promise((r) => setTimeout(r, executionTime)); return 2; }, { retried: false }, ), ); resolve(); }, ); }, ); const numbers = await Promise.all(promises); const totalExecutionTime = Date.now() - now; /** * 1 pauses immediately, * then pauses the queue for the default wait time. * During the pause, 2 gets queued, but must wait due to the pause. * After the pause is over, 1 executes again and returns after executionTime. * 2 also executes and sets up another pause. * After the pause is over, 2 executes again and returns after executionTime. * * Total time is the two pauses + 2's execution time, since 1 completes its execution before that. */ expect(totalExecutionTime).toBeGreaterThanOrEqual( DEFAULT_WAIT + DEFAULT_WAIT + executionTime, ); expect(numbers).toEqual([1, 2]); }); it('enables the queue to pause executions not already in-flight with the supplied wait time', async () => { const throttle = throttledQueue(); const waitTime = DEFAULT_WAIT / 2; const executionTime = waitTime / 2; const now = Date.now(); const promises: Promise<number>[] = []; promises.push( throttle( async ({ state }) => { if (!state.retried) { state.retried = true; throw new RetryError({ pauseQueue: true, retryAfter: waitTime }); } await new Promise((resolve) => setTimeout(resolve, executionTime)); return 1; }, { retried: false }, ), ); // we need to add to the queue in the next tick to witness the paused execution await new Promise((resolve) => setTimeout(resolve, 0)); promises.push( throttle( async ({ state }) => { if (!state.retried) { state.retried = true; throw new RetryError({ pauseQueue: true, retryAfter: waitTime }); } await new Promise((r) => setTimeout(r, executionTime)); return 2; }, { retried: false }, ), ); const numbers = await Promise.all(promises); const totalExecutionTime = Date.now() - now; /** * 1 pauses immediately, * then pauses the queue for the default wait time. * During the pause, 2 gets queued, but must wait due to the pause. * After the pause is over, 1 executes again and returns after executionTime. * 2 also executes and sets up another pause. * After the pause is over, 2 executes again and returns after executionTime. * * Total time is the two pauses + 2's execution time, since 1 completes its execution before that. */ expect(totalExecutionTime).toBeGreaterThanOrEqual( waitTime + waitTime + executionTime, ); expect(numbers).toEqual([1, 2]); }); it( 'enables the queue to pause executions not already in-flight using interval as wait time when supplied', async () => { const interval = DEFAULT_WAIT / 2; const throttle = throttledQueue({ interval }); const executionTime = interval / 2; const now = Date.now(); const promises: Promise<number>[] = []; promises.push( throttle( async ({ state }) => { if (!state.retried) { state.retried = true; throw new RetryError({ pauseQueue: true }); } await new Promise((resolve) => setTimeout(resolve, executionTime)); return 1; }, { retried: false }, ), ); // we need to add to the queue in the next tick to witness the paused execution await new Promise((resolve) => setTimeout(resolve, 0)); promises.push( throttle( async ({ state }) => { if (!state.retried) { state.retried = true; throw new RetryError({ pauseQueue: true }); } await new Promise((r) => setTimeout(r, executionTime)); return 2; }, { retried: false }, ), ); const numbers = await Promise.all(promises); const totalExecutionTime = Date.now() - now; /** * 1 pauses immediately, * then pauses the queue for the interval wait time. * During the pause, 2 gets queued, but must wait due to the pause. * After the pause is over, 1 executes again and returns after executionTime. * 2 also executes and sets up another pause. * After the pause is over, 2 executes again and returns after executionTime. * * Total time is the two pauses + 2's execution time, since 1 completes its execution before that. */ expect(totalExecutionTime).toBeGreaterThanOrEqual( interval + interval + executionTime, ); expect(numbers).toEqual([1, 2]); }); }); });