UNPKG

redis-sliding-rate-limiter

Version:

Flexible and performant rate limiter based on sliding window algorithm with arbitrary precision

668 lines (548 loc) 21 kB
import { RateLimiter, RateLimiterResponse, Unit } from '..'; import { createRedisClient, createIORedisClient, flushRedis, validateLimiterResponse, runTestConfig, TestConfig, BatchRequest, ValidateFn, } from './shared'; describe('RateLimiter', () => { jest.setTimeout(240000); const testClients: any[] = [ [ 'redis', createRedisClient() ], [ 'ioredis', createIORedisClient() ], ]; beforeAll(async () => { // Connect redis client if needed for (const [ name, client ] of testClients) { if (name === 'redis' && typeof client.connect === 'function') { try { await client.connect(); } catch(err) { console.log(err) } // Ignore 'already connected' or 'connecting' errors } } }); afterAll(async () => { for (const [ name, client ] of testClients) { console.log(`Closing client ${name}`); try { await client.quit(); } catch(err) { try { client.quit(); } catch(err) { console.log(err) } } } }); for (const [ name, client ] of testClients) { const tag = `[${name}]`; /** * 10 requests per second with subdivision at decisecond. * * 1 request in the first decisecond (first second): expected succeded=1 failed=0. * 10 requests in the last decisecond (first second): expected succeded=9 failed=1. * 2 requests in the third decisecond (second second): expected succeded=1 failed=1. */ it(`${tag} 10req/1sec, subdivision=decisecond`, async () => { const limiter = new RateLimiter({ client: client, window: { unit: Unit.SECOND, size: 1, subdivisionUnit: Unit.DECISECOND, }, limit: 10 }); // Flush Redis await flushRedis(limiter); const key = `${tag} 10req/1sec, subdivision=decisecond`; const firstBatch: BatchRequest = { delay: 0, size: 1, validate: (res) => { const received = res[0]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: limiter.limit - 1 }; validateLimiterResponse(received, expected); } }; const secondBatch: BatchRequest = { delay: 900, size: 10, validate: (res) => { for (let i = 0; i < 10; ++i) { const received = res[i]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: Math.max(0, limiter.limit - i - 2) }; // Last request expected to fail if (i === 9) { expected.allowed = false; } validateLimiterResponse(received, expected); } } }; const thirdBatch: BatchRequest = { delay: 1100, size: 2, validate: (res) => { for (let i = 0; i < 2; ++i) { const received = res[i]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: 0 }; // Last request expected to fail if (i === 1) { expected.allowed = false; } validateLimiterResponse(received, expected); } } }; const config: TestConfig = { limiter, key, batches: [firstBatch, secondBatch, thirdBatch], }; await runTestConfig(config); }); /** * 3 requests per 3 second with subdivision at second. * * 1 request in the first second: expected succeded=1 failed=0. * 3 requests in the third second: expected succeded=2 failed=1. * 2 requests in the fourth second: expected succeded=1 failed=1. */ it(`${tag} 3req/3sec, subdivision=second`, async () => { const limiter = new RateLimiter({ client: client, window: { unit: Unit.SECOND, size: 3, subdivisionUnit: Unit.DECISECOND, }, limit: 3 }); // Flush Redis await flushRedis(limiter); const key = `${tag} 3req/3sec, subdivision=second`; const firstBatch: BatchRequest = { delay: 0, size: 1, validate: (res) => { const received = res[0]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: limiter.limit - 1 }; validateLimiterResponse(received, expected); } }; const secondBatch: BatchRequest = { delay: 2000, size: 3, validate: (res) => { for (let i = 0; i < 3; ++i) { const received = res[i]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: Math.max(0, limiter.limit - i - 2) }; // Last request expected to fail if (i === 2) { expected.allowed = false; } validateLimiterResponse(received, expected); } } }; const thirdBatch: BatchRequest = { delay: 3100, size: 2, validate: (res) => { for (let i = 0; i < 2; ++i) { const received = res[i]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: 0 }; // Last request expected to fail if (i === 1) { expected.allowed = false; } validateLimiterResponse(received, expected); } } }; const config: TestConfig = { limiter, key, batches: [firstBatch, secondBatch, thirdBatch] }; await runTestConfig(config); }); /** * 10 requests per 1 decisecond with subdivision at decisecond. * * 9 requests every decisecond: expected succeded=9 failed=0. */ it(`${tag} 10req/1decisec, subdivision=decisecond`, async () => { const limiter = new RateLimiter({ client: client, window: { unit: Unit.DECISECOND, size: 1, subdivisionUnit: Unit.DECISECOND, }, limit: 10 }); // Flush Redis await flushRedis(limiter); const key = `${tag} 10req/1decisec, subdivision=decisecond`; const numBatches = 50; const batchSize = 9; const batches: BatchRequest[] = []; const delayTimeIncrMs = 100; // Increase delay of 1 decisecond for each batch let delay = 0; const _validateBatch: ValidateFn = (res) => { for (let i = 0; i < batchSize; ++i) { const received = res[i]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: Math.max(0, limiter.limit - i - 1), }; validateLimiterResponse(received, expected); } } for (let i = 0; i < numBatches; ++i) { batches.push({ size: batchSize, delay: delay, validate: _validateBatch }); delay += delayTimeIncrMs; } const config: TestConfig = { limiter, key, batches }; await runTestConfig(config); }); /** * 1000 requests per 1 second with subdivision at second. * * 1000 requests every second: expected succeded=1000 failed=0. */ it(`${tag} 1000req/1sec, subdivision=second`, async () => { const limiter = new RateLimiter({ client: client, window: { unit: Unit.SECOND, size: 1, subdivisionUnit: Unit.SECOND, }, limit: 1000 }); // Flush Redis await flushRedis(limiter); const key = `${tag} 1000req/1sec, subdivision=second`; const numBatches = 3; const batchSize = 1000; const batches: BatchRequest[] = []; const delayTimeIncrMs = 1000; // Increase delay of 1 second for each batch let delay = 0; const _validateBatch: ValidateFn = (res) => { for (let i = 0; i < batchSize; ++i) { const received = res[i]; const expected: Partial<RateLimiterResponse> = { allowed: true, }; validateLimiterResponse(received, expected); } } for (let i = 0; i < numBatches; ++i) { batches.push({ size: batchSize, delay: delay, validate: _validateBatch }); delay += delayTimeIncrMs; } const config: TestConfig = { limiter, key, batches }; await runTestConfig(config); }); /** * Test first member and window expiration timestamps - 3req/2sec, subdivision=second. * * 1 request in the first second: expected succeded=1 failed=0. * 3 requests in the second second: expected succeded=2 failed=1. * 2 requests at the first member timestamp: expected succeded=1 failed=1. */ it(`${tag} First member and window expiration timestamps - 3req/2sec, subdivision=second`, async () => { const limiter = new RateLimiter({ client: client, window: { unit: Unit.SECOND, size: 2, subdivisionUnit: Unit.SECOND, }, limit: 3 }); // Flush Redis await flushRedis(limiter); const key = `${tag} First member and window expiration timestamps - 3req/2sec, subdivision=second`; const firstBatch: BatchRequest = { delay: 0, size: 1, validate: (res) => { const received = res[0]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: limiter.limit - 1 }; validateLimiterResponse(received, expected); } }; const secondBatch: BatchRequest = { delay: 1000, size: 3, validate: (res) => { for (let i = 0; i < 3; ++i) { const received = res[i]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: Math.max(0, limiter.limit - i - 2), }; // Expect last request to fail if (i === 2) { expected.allowed = false; } validateLimiterResponse(received, expected); } } }; let batches = [firstBatch, secondBatch]; let config: TestConfig = { limiter, key, batches }; let batchResults = await runTestConfig(config); // Test first member expiration timestamp (from last request) let lastBatchResult = batchResults[batchResults.length - 1]; let { firstExpireAtMs } = lastBatchResult[lastBatchResult.length - 1]; let firstExpireAtMsDelta = firstExpireAtMs - Date.now(); const thirdBatch: BatchRequest = { delay: firstExpireAtMsDelta, size: 2, validate: (res) => { for (let i = 0; i < 2; ++i) { const received = res[i]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: 0, }; // Expect last request to fail if (i === 1) { expected.allowed = false; } validateLimiterResponse(received, expected); } } }; batches = [thirdBatch]; config = { limiter, key, batches }; batchResults = await runTestConfig(config); // Test window expiration timestamp (from last request) lastBatchResult = batchResults[batchResults.length - 1]; firstExpireAtMs = lastBatchResult[lastBatchResult.length - 1].firstExpireAtMs; let windowExpireAtMs = lastBatchResult[lastBatchResult.length - 1].windowExpireAtMs; firstExpireAtMsDelta = firstExpireAtMs - Date.now(); const windowExpireAtMsDelta = windowExpireAtMs - Date.now(); const fourthBatch: BatchRequest = { delay: windowExpireAtMsDelta, size: limiter.limit, validate: (res) => { for (let i = 0; i < limiter.limit; ++i) { const received = res[i]; const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: Math.max(0, limiter.limit - i - 1), }; validateLimiterResponse(received, expected); } } }; batches = [fourthBatch]; config = { limiter, key, batches }; await runTestConfig(config); expect(firstExpireAtMsDelta).toBeGreaterThan(0); expect(windowExpireAtMsDelta).toBeGreaterThan(0); expect(windowExpireAtMsDelta).toBeGreaterThan(firstExpireAtMsDelta); }); /** * Limit Overhead * * limit=10, limitOverheadFraction=0.1 * 12 requests in 1 second: expected succeded=11, failed=1 */ it(`${tag} Limit Overhead`, async () => { const limiter = new RateLimiter({ client: client, window: { unit: Unit.SECOND, size: 1, subdivisionUnit: Unit.SECOND, }, limit: 10, limitOverhead: 0.1, }); // Flush Redis await flushRedis(limiter); const key = `${tag} Limit Overhead`; const batchSize = limiter.limit + Math.floor(limiter.limit * limiter.limitOverheadFraction) + 1; const batch: BatchRequest = { delay: 0, size: batchSize, validate: (res) => { for (let i = 0; i < batchSize; ++i) { const received = res[i]; // Expect fourth and fifth requests to succeed thanks to limit overhead const expected: Partial<RateLimiterResponse> = { allowed: true, remaining: Math.max(0, limiter.limit - i - 1), }; // Expect last request to fail if (i === batchSize - 1) { expected.allowed = false; } validateLimiterResponse(received, expected); } } }; const config: TestConfig = { limiter, key, batches: [batch] }; await runTestConfig(config); }); /** * Test conversions */ it(`${tag} Conversions`, () => { const limit = 3; let limiter = new RateLimiter({ client, limit, window: { unit: Unit.MINUTE, size: 1, }, }); let { window, windowExpireMs } = limiter; expect(window).toBe(1); expect(windowExpireMs).toBe(1000 * 60); limiter = new RateLimiter({ client, limit, window: { unit: Unit.MINUTE, size: 2, subdivisionUnit: Unit.SECOND }, }); ({ window, windowExpireMs } = limiter); expect(window).toBe(60 * 2); expect(windowExpireMs).toBe(1000 * 60 * 2); limiter = new RateLimiter({ client, limit, window: { unit: Unit.MINUTE, size: 5, subdivisionUnit: Unit.CENTISECOND, } }); ({ window, windowExpireMs } = limiter); expect(window).toBe(10 * 10 * 60 * 5); expect(windowExpireMs).toBe(1000 * 60 * 5); limiter = new RateLimiter({ client, limit, window: { unit: Unit.HOUR, size: 1, subdivisionUnit: Unit.MINUTE }, }); ({ window, windowExpireMs } = limiter); expect(window).toBe(60); expect(windowExpireMs).toBe(1000 * 60 * 60); limiter = new RateLimiter({ client, limit, window: { unit: Unit.HOUR, size: 1, subdivisionUnit: Unit.SECOND }, }); ({ window, windowExpireMs } = limiter); expect(window).toBe(60 * 60); expect(windowExpireMs).toBe(1000 * 60 * 60); limiter = new RateLimiter({ client, limit, window: { unit: Unit.HOUR, size: 3, subdivisionUnit: Unit.DECISECOND }, }); ({ window, windowExpireMs } = limiter); expect(window).toBe(10 * 60 * 60 * 3); expect(windowExpireMs).toBe(1000 * 60 * 60 * 3); const createLimiter = () => { return new RateLimiter({ client, limit, window: { unit: Unit.SECOND, size: 1, subdivisionUnit: Unit.MINUTE }, }); } expect(createLimiter).toThrow('window.subdivisionUnit must be lower or equal to window.unit'); }); } });