redis-sliding-rate-limiter
Version:
Flexible and performant rate limiter based on sliding window algorithm with arbitrary precision
668 lines (548 loc) • 21 kB
text/typescript
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');
});
}
});