rolling-rate-limiter
Advanced tools
Comparing version
/// <reference types="node" /> | ||
export declare type Id = number | string; | ||
export declare type Seconds = number & { | ||
export type Id = number | string; | ||
export type Seconds = number & { | ||
__brand: 'seconds'; | ||
}; | ||
export declare type Milliseconds = number & { | ||
export type Milliseconds = number & { | ||
__brand: 'milliseconds'; | ||
}; | ||
export declare type Microseconds = number & { | ||
export type Microseconds = number & { | ||
__brand: 'microseconds'; | ||
@@ -83,36 +83,110 @@ }; | ||
/** | ||
* Minimal interface of a Redis client needed for algorithm. | ||
* Ideally, this would be `RedisClient | IORedisClient`, but that would force consumers of this | ||
* library to have `@types/redis` and `@types/ioredis` to be installed. | ||
* Wrapper class around a Redis client. | ||
* Exposes only the methods we need for the algorithm. | ||
* This papers over differences between `node-redis` and `ioredis`. | ||
*/ | ||
interface RedisClient { | ||
del(...args: Array<string>): unknown; | ||
multi(): RedisBatch; | ||
interface RedisClientWrapper { | ||
del(arg: string): unknown; | ||
multi(): RedisMultiWrapper; | ||
parseZRangeResult(result: unknown): Array<Microseconds>; | ||
} | ||
/** Minimal interface of a Redis batch command needed for algorithm. */ | ||
interface RedisBatch { | ||
zremrangebyscore(key: string, min: number, max: number): void; | ||
zadd(key: string, score: string | number, value: string): void; | ||
zrange(key: string, min: number, max: number, withScores: unknown): void; | ||
/** | ||
* Wrapper class around a Redis multi batch. | ||
* Exposes only the methods we need for the algorithm. | ||
* This papers over differences between `node-redis` and `ioredis`. | ||
*/ | ||
interface RedisMultiWrapper { | ||
zRemRangeByScore(key: string, min: number, max: number): void; | ||
zAdd(key: string, score: number, value: string): void; | ||
zRangeWithScores(key: string, min: number, max: number): void; | ||
expire(key: string, time: number): void; | ||
exec(cb: (err: Error | null, result: Array<unknown>) => void): void; | ||
exec(): Promise<Array<unknown>>; | ||
} | ||
interface RedisRateLimiterOptions extends RateLimiterOptions { | ||
client: RedisClient; | ||
/** | ||
* Generic options for constructing a Redis-backed rate limiter. | ||
* See `README.md` for more information. | ||
*/ | ||
interface RedisRateLimiterOptions<Client> extends RateLimiterOptions { | ||
client: Client; | ||
namespace: string; | ||
} | ||
/** | ||
* Rate limiter implementation that uses Redis for storage. | ||
* Abstract base class for Redis-based implementations. | ||
*/ | ||
export declare class RedisRateLimiter extends RateLimiter { | ||
client: RedisClient; | ||
declare abstract class BaseRedisRateLimiter extends RateLimiter { | ||
client: RedisClientWrapper; | ||
namespace: string; | ||
ttl: number; | ||
constructor({ client, namespace, ...baseOptions }: RedisRateLimiterOptions); | ||
constructor({ client, namespace, ...baseOptions }: RedisRateLimiterOptions<RedisClientWrapper>); | ||
makeKey(id: Id): string; | ||
clear(id: Id): Promise<void>; | ||
protected getTimestamps(id: Id, addNewTimestamp: boolean): Promise<Array<Microseconds>>; | ||
private getZRangeResult; | ||
private extractTimestampsFromZRangeResult; | ||
} | ||
/** | ||
* Duck-typed `node-redis` client. We don't want to use the actual typing because that would | ||
* force users to install `node-redis` as a peer dependency. | ||
*/ | ||
interface NodeRedisClient { | ||
del(arg: string): unknown; | ||
multi(): NodeRedisMulti; | ||
} | ||
/** | ||
* Duck-typed `node-redis` multi object. We don't want to use the actual typing because that would | ||
* force users to install `node-redis` as a peer dependency. | ||
*/ | ||
interface NodeRedisMulti { | ||
zRemRangeByScore(key: string, min: number, max: number): void; | ||
zAdd(key: string, item: { | ||
score: number; | ||
value: string; | ||
}): void; | ||
zRangeWithScores(key: string, min: number, max: number): void; | ||
expire(key: string, time: number): void; | ||
exec(): Promise<Array<unknown>>; | ||
} | ||
/** | ||
* Rate limiter backed by `node-redis`. | ||
*/ | ||
export declare class NodeRedisRateLimiter extends BaseRedisRateLimiter { | ||
constructor({ client, ...baseOptions }: RedisRateLimiterOptions<NodeRedisClient>); | ||
} | ||
/** | ||
* Duck-typed `ioredis` client. We don't want to use the actual typing because that would | ||
* force users to install `ioredis` as a peer dependency. | ||
*/ | ||
interface IORedisClient { | ||
del(arg: string): unknown; | ||
multi(): IORedisMulti; | ||
} | ||
/** | ||
* Duck-typed `ioredis` multi object. We don't want to use the actual typing because that would | ||
* force users to install `ioredis` as a peer dependency. | ||
*/ | ||
interface IORedisMulti { | ||
zremrangebyscore(key: string, min: number, max: number): void; | ||
zadd(key: string, score: number, value: string): void; | ||
zrange(key: string, min: number, max: number, withScores: 'WITHSCORES'): void; | ||
expire(key: string, time: number): void; | ||
exec(): Promise<Array<[error: Error | null, result: unknown]> | null>; | ||
} | ||
/** | ||
* Rate limiter backed by `ioredis`. | ||
*/ | ||
export declare class IORedisRateLimiter extends BaseRedisRateLimiter { | ||
constructor({ client, ...baseOptions }: RedisRateLimiterOptions<IORedisClient>); | ||
} | ||
type RedisClientType = 'node-redis' | 'ioredis'; | ||
/** | ||
* Rate limiter backed by either `node-redis` or `ioredis`. | ||
* Uses duck-typing to determine which client is being used. | ||
*/ | ||
export declare class RedisRateLimiter extends BaseRedisRateLimiter { | ||
/** | ||
* Given an unknown object, determine what type of redis client it is. | ||
* Used by the constructor of this class. | ||
*/ | ||
static determineRedisClientType(client: any): RedisClientType | null; | ||
readonly detectedClientType: RedisClientType; | ||
constructor({ client, ...baseOptions }: RedisRateLimiterOptions<any>); | ||
} | ||
export declare function getCurrentMicroseconds(): Microseconds; | ||
@@ -119,0 +193,0 @@ export declare function millisecondsToMicroseconds(milliseconds: Milliseconds): Microseconds; |
171
lib/index.js
@@ -6,3 +6,3 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.microsecondsToSeconds = exports.microsecondsToMilliseconds = exports.millisecondsToMicroseconds = exports.getCurrentMicroseconds = exports.RedisRateLimiter = exports.InMemoryRateLimiter = exports.RateLimiter = void 0; | ||
exports.microsecondsToSeconds = exports.microsecondsToMilliseconds = exports.millisecondsToMicroseconds = exports.getCurrentMicroseconds = exports.RedisRateLimiter = exports.IORedisRateLimiter = exports.NodeRedisRateLimiter = exports.InMemoryRateLimiter = exports.RateLimiter = void 0; | ||
const assert_1 = __importDefault(require("assert")); | ||
@@ -75,3 +75,3 @@ const microtime_1 = __importDefault(require("microtime")); | ||
// Only performs the check for positive `minDifference` values. The `currentTimestamp` | ||
// created by `wouldLimit` may possibly be smaller than `previousTimestamp` in a distributed | ||
// created by `wouldLimit` may possibly be smaller than `previousTimestamp` in a distributed | ||
// environment. | ||
@@ -140,5 +140,5 @@ this.minDifference > 0 && | ||
/** | ||
* Rate limiter implementation that uses Redis for storage. | ||
* Abstract base class for Redis-based implementations. | ||
*/ | ||
class RedisRateLimiter extends RateLimiter { | ||
class BaseRedisRateLimiter extends RateLimiter { | ||
constructor({ client, namespace, ...baseOptions }) { | ||
@@ -162,34 +162,149 @@ super(baseOptions); | ||
const batch = this.client.multi(); | ||
batch.zremrangebyscore(key, 0, clearBefore); | ||
batch.zRemRangeByScore(key, 0, clearBefore); | ||
if (addNewTimestamp) { | ||
batch.zadd(key, String(now), (0, uuid_1.v4)()); | ||
batch.zAdd(key, now, (0, uuid_1.v4)()); | ||
} | ||
batch.zrange(key, 0, -1, 'WITHSCORES'); | ||
batch.zRangeWithScores(key, 0, -1); | ||
batch.expire(key, this.ttl); | ||
return new Promise((resolve, reject) => { | ||
batch.exec((err, result) => { | ||
if (err) | ||
return reject(err); | ||
const zRangeOutput = (addNewTimestamp ? result[2] : result[1]); | ||
const zRangeResult = this.getZRangeResult(zRangeOutput); | ||
const timestamps = this.extractTimestampsFromZRangeResult(zRangeResult); | ||
return resolve(timestamps); | ||
}); | ||
}); | ||
const results = await batch.exec(); | ||
const zRangeResult = addNewTimestamp ? results[2] : results[1]; | ||
return this.client.parseZRangeResult(zRangeResult); | ||
} | ||
getZRangeResult(zRangeOutput) { | ||
if (!Array.isArray(zRangeOutput[1])) { | ||
// Standard redis client, regular mode. | ||
return zRangeOutput; | ||
} | ||
/** | ||
* Wrapper for `node-redis` client, proxying method calls to the underlying client. | ||
*/ | ||
class NodeRedisClientWrapper { | ||
constructor(client) { | ||
this.client = client; | ||
} | ||
del(arg) { | ||
return this.client.del(arg); | ||
} | ||
multi() { | ||
return new NodeRedisMultiWrapper(this.client.multi()); | ||
} | ||
parseZRangeResult(result) { | ||
return result.map(({ score }) => score); | ||
} | ||
} | ||
/** | ||
* Wrapper for `node-redis` multi batch, proxying method calls to the underlying client. | ||
*/ | ||
class NodeRedisMultiWrapper { | ||
constructor(multi) { | ||
this.multi = multi; | ||
} | ||
zRemRangeByScore(key, min, max) { | ||
this.multi.zRemRangeByScore(key, min, max); | ||
} | ||
zAdd(key, score, value) { | ||
this.multi.zAdd(key, { score: Number(score), value }); | ||
} | ||
zRangeWithScores(key, min, max) { | ||
this.multi.zRangeWithScores(key, min, max); | ||
} | ||
expire(key, time) { | ||
this.multi.expire(key, time); | ||
} | ||
async exec() { | ||
// TODO: ensure everything is a string? | ||
return this.multi.exec(); | ||
} | ||
} | ||
/** | ||
* Rate limiter backed by `node-redis`. | ||
*/ | ||
class NodeRedisRateLimiter extends BaseRedisRateLimiter { | ||
constructor({ client, ...baseOptions }) { | ||
super({ client: new NodeRedisClientWrapper(client), ...baseOptions }); | ||
} | ||
} | ||
exports.NodeRedisRateLimiter = NodeRedisRateLimiter; | ||
/** | ||
* Wrapper for `ioredis` client, proxying method calls to the underlying client. | ||
*/ | ||
class IORedisClientWrapper { | ||
constructor(client) { | ||
this.client = client; | ||
} | ||
del(arg) { | ||
return this.client.del(arg); | ||
} | ||
multi() { | ||
return new IORedisMultiWrapper(this.client.multi()); | ||
} | ||
parseZRangeResult(result) { | ||
const valuesAndScores = result[1]; | ||
return valuesAndScores.filter((e, i) => i % 2).map(Number); | ||
} | ||
} | ||
/** | ||
* Wrapper for `ioredis` multi batch, proxying method calls to the underlying client. | ||
*/ | ||
class IORedisMultiWrapper { | ||
constructor(multi) { | ||
this.multi = multi; | ||
} | ||
zRemRangeByScore(key, min, max) { | ||
this.multi.zremrangebyscore(key, min, max); | ||
} | ||
zAdd(key, score, value) { | ||
this.multi.zadd(key, score, value); | ||
} | ||
zRangeWithScores(key, min, max) { | ||
this.multi.zrange(key, min, max, 'WITHSCORES'); | ||
} | ||
expire(key, time) { | ||
this.multi.expire(key, time); | ||
} | ||
async exec() { | ||
var _a; | ||
return (_a = (await this.multi.exec())) !== null && _a !== void 0 ? _a : []; | ||
} | ||
} | ||
/** | ||
* Rate limiter backed by `ioredis`. | ||
*/ | ||
class IORedisRateLimiter extends BaseRedisRateLimiter { | ||
constructor({ client, ...baseOptions }) { | ||
super({ client: new IORedisClientWrapper(client), ...baseOptions }); | ||
} | ||
} | ||
exports.IORedisRateLimiter = IORedisRateLimiter; | ||
/** | ||
* Rate limiter backed by either `node-redis` or `ioredis`. | ||
* Uses duck-typing to determine which client is being used. | ||
*/ | ||
class RedisRateLimiter extends BaseRedisRateLimiter { | ||
/** | ||
* Given an unknown object, determine what type of redis client it is. | ||
* Used by the constructor of this class. | ||
*/ | ||
static determineRedisClientType(client) { | ||
if ('zRemRangeByScore' in client && 'ZREMRANGEBYSCORE' in client) | ||
return 'node-redis'; | ||
if ('zremrangebyscore' in client) | ||
return 'ioredis'; | ||
return null; | ||
} | ||
constructor({ client, ...baseOptions }) { | ||
const clientType = RedisRateLimiter.determineRedisClientType(client); | ||
if (clientType == null) { | ||
throw new Error('Could not detect redis client type'); | ||
} | ||
else if (clientType === 'node-redis') { | ||
super({ | ||
client: new NodeRedisClientWrapper(client), | ||
...baseOptions, | ||
}); | ||
} | ||
else { | ||
// ioredis client. | ||
return zRangeOutput[1]; | ||
super({ | ||
client: new IORedisClientWrapper(client), | ||
...baseOptions, | ||
}); | ||
} | ||
this.detectedClientType = clientType; | ||
} | ||
extractTimestampsFromZRangeResult(zRangeResult) { | ||
// We only want the stored timestamps, which are the values, or the odd indexes. | ||
// Map to numbers because by default all returned values are strings. | ||
return zRangeResult.filter((e, i) => i % 2).map(Number); | ||
} | ||
} | ||
@@ -196,0 +311,0 @@ exports.RedisRateLimiter = RedisRateLimiter; |
"use strict"; | ||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { | ||
if (k2 === undefined) k2 = k; | ||
var desc = Object.getOwnPropertyDescriptor(m, k); | ||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { | ||
desc = { enumerable: true, get: function() { return m[k]; } }; | ||
} | ||
Object.defineProperty(o, k2, desc); | ||
}) : (function(o, m, k, k2) { | ||
if (k2 === undefined) k2 = k; | ||
o[k2] = m[k]; | ||
})); | ||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { | ||
Object.defineProperty(o, "default", { enumerable: true, value: v }); | ||
}) : function(o, v) { | ||
o["default"] = v; | ||
}); | ||
var __importStar = (this && this.__importStar) || function (mod) { | ||
if (mod && mod.__esModule) return mod; | ||
var result = {}; | ||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); | ||
__setModuleDefault(result, mod); | ||
return result; | ||
}; | ||
var __importDefault = (this && this.__importDefault) || function (mod) { | ||
@@ -8,3 +31,3 @@ return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
const microtime_1 = __importDefault(require("microtime")); | ||
const redis_1 = __importDefault(require("redis")); | ||
const redis = __importStar(require("redis")); | ||
const _1 = require("."); | ||
@@ -62,6 +85,25 @@ describe('options validation', () => { | ||
}); | ||
describe('client detection', () => { | ||
const options = { | ||
namespace: 'test', | ||
interval: 10, | ||
maxInInterval: 2, | ||
}; | ||
it('detects node-redis client', () => { | ||
const client = redis.createClient(); | ||
const limiter = new _1.RedisRateLimiter({ client, ...options }); | ||
expect(limiter.detectedClientType).toBe('node-redis'); | ||
}); | ||
it('detects io-redis client', () => { | ||
const client = new ioredis_1.default(); | ||
const limiter = new _1.RedisRateLimiter({ client, ...options }); | ||
expect(limiter.detectedClientType).toBe('ioredis'); | ||
client.quit(); | ||
}); | ||
it('throws on invalid types', () => { | ||
const client = { connect: () => { } }; | ||
expect(() => new _1.RedisRateLimiter({ client, ...options })).toThrowError(); | ||
}); | ||
}); | ||
describe('RateLimiter implementations', () => { | ||
beforeEach(() => jest.useFakeTimers()); | ||
afterEach(() => jest.runAllTimers()); | ||
let currentTime = 0; | ||
function setTime(timeInMilliseconds) { | ||
@@ -71,4 +113,2 @@ jest | ||
.mockImplementation(() => (0, _1.millisecondsToMicroseconds)(timeInMilliseconds)); | ||
jest.advanceTimersByTime(Math.max(0, timeInMilliseconds - currentTime)); | ||
currentTime = timeInMilliseconds; | ||
} | ||
@@ -260,27 +300,25 @@ function sharedExamples(_createLimiter) { | ||
}); | ||
describe('RedisRateLimiter (`redis` client)', () => { | ||
describe('node-redis client', () => { | ||
let client; | ||
beforeEach(() => { | ||
client = redis_1.default.createClient(); | ||
beforeEach(async () => { | ||
client = redis.createClient(); | ||
await client.connect(); | ||
}); | ||
afterEach((cb) => client.quit(cb)); | ||
sharedExamples((opts) => new _1.RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-redis', | ||
...opts, | ||
})); | ||
}); | ||
describe('RedisRateLimiter (`redis` client, `return_buffers` enabled)', () => { | ||
let client; | ||
beforeEach(() => { | ||
client = redis_1.default.createClient({ return_buffers: true }); | ||
afterEach(() => client.quit()); | ||
describe('NodeRedisRateLimiter', () => { | ||
sharedExamples((opts) => new _1.NodeRedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-redis-1', | ||
...opts, | ||
})); | ||
}); | ||
afterEach((cb) => client.quit(cb)); | ||
sharedExamples((opts) => new _1.RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-redis', | ||
...opts, | ||
})); | ||
describe('RedisRateLimiter', () => { | ||
sharedExamples((opts) => new _1.RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-redis-2', | ||
...opts, | ||
})); | ||
}); | ||
}); | ||
describe('RedisRateLimiter (`ioredis` client)', () => { | ||
describe('ioredis client', () => { | ||
let client; | ||
@@ -290,9 +328,18 @@ beforeEach(() => { | ||
}); | ||
afterEach((cb) => client.quit(cb)); | ||
sharedExamples((opts) => new _1.RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-ioredis', | ||
...opts, | ||
})); | ||
afterEach(() => client.quit()); | ||
describe('IORedisRateLimiter', () => { | ||
sharedExamples((opts) => new _1.IORedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-ioredis-1', | ||
...opts, | ||
})); | ||
}); | ||
describe('RedisRateLimiter', () => { | ||
sharedExamples((opts) => new _1.RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-ioredis-2', | ||
...opts, | ||
})); | ||
}); | ||
}); | ||
}); |
{ | ||
"name": "rolling-rate-limiter", | ||
"version": "0.3.0", | ||
"version": "0.4.0", | ||
"description": "Rate limiter that supports a rolling window, either in-memory or backed by Redis", | ||
@@ -42,19 +42,17 @@ "main": "lib/index.js", | ||
"microtime": "^3.0.0", | ||
"uuid": "^8.3.0" | ||
"uuid": "^9.0.0" | ||
}, | ||
"devDependencies": { | ||
"@types/ioredis": "^4.26.0", | ||
"@types/jest": "^26.0.23", | ||
"@types/jest": "^29.4.0", | ||
"@types/microtime": "^2.1.0", | ||
"@types/node": "^15.0.1", | ||
"@types/redis": "^2.8.28", | ||
"@types/uuid": "^8.3.0", | ||
"eslint": "^7.25.0", | ||
"@types/node": "^18.13.0", | ||
"@types/uuid": "^9.0.0", | ||
"eslint": "^8.34.0", | ||
"eslint-config-peterkhayes": "^4.0.0", | ||
"ioredis": "^4.27.1", | ||
"jest": "^26.6.3", | ||
"redis": "^3.1.2", | ||
"ts-jest": "^26.5.5", | ||
"typescript": "^4.2.4" | ||
"ioredis": "^5.3.1", | ||
"jest": "^29.4.2", | ||
"redis": "^4.6.4", | ||
"ts-jest": "^29.0.5", | ||
"typescript": "^4.9.5" | ||
} | ||
} |
@@ -19,3 +19,3 @@ # Rolling Rate Limiter | ||
```javascript | ||
const { RedisRateLimiter } = require("rolling-rate-limiter"); | ||
const { NodeRedisRateLimiter } = require("rolling-rate-limiter"); | ||
@@ -42,4 +42,7 @@ const limiter = new RedisRateLimiter({ | ||
- `RedisRateLimiter` - Stores state in Redis. Can use `redis` or `ioredis` clients. | ||
- `InMemoryRateLimiter` - Stores state in memory. Useful in testing or outside of web servers. | ||
- Redis rate limiters: There are two main redis clients for node: [redis (aka node-redis)](https://github.com/redis/node-redis) and [ioredis](https://github.com/luin/ioredis). Both are supported: | ||
- `RedisRateLimiter` - Attempts to detect whether it was passed a `redis` or `ioredis` client. | ||
- `NodeRedisRateLimiter` - No detection; only works with `redis` client. | ||
- `IORedisRateLimiter` - No detection; only works with `ioredis` client. | ||
@@ -46,0 +49,0 @@ ## Configuration options |
import IORedis from 'ioredis'; | ||
import microtime from 'microtime'; | ||
import redis from 'redis'; | ||
import * as redis from 'redis'; | ||
import { | ||
InMemoryRateLimiter, | ||
IORedisRateLimiter, | ||
Milliseconds, | ||
millisecondsToMicroseconds, | ||
NodeRedisRateLimiter, | ||
RateLimiter, | ||
RateLimiterOptions, | ||
InMemoryRateLimiter, | ||
RedisRateLimiter, | ||
millisecondsToMicroseconds, | ||
Milliseconds, | ||
} from '.'; | ||
@@ -76,7 +78,29 @@ | ||
describe('client detection', () => { | ||
const options = { | ||
namespace: 'test', | ||
interval: 10, | ||
maxInInterval: 2, | ||
}; | ||
it('detects node-redis client', () => { | ||
const client = redis.createClient(); | ||
const limiter = new RedisRateLimiter({ client, ...options }); | ||
expect(limiter.detectedClientType).toBe('node-redis'); | ||
}); | ||
it('detects io-redis client', () => { | ||
const client = new IORedis(); | ||
const limiter = new RedisRateLimiter({ client, ...options }); | ||
expect(limiter.detectedClientType).toBe('ioredis'); | ||
client.quit(); | ||
}); | ||
it('throws on invalid types', () => { | ||
const client = { connect: () => {} }; | ||
expect(() => new RedisRateLimiter({ client, ...options })).toThrowError(); | ||
}); | ||
}); | ||
describe('RateLimiter implementations', () => { | ||
beforeEach(() => jest.useFakeTimers()); | ||
afterEach(() => jest.runAllTimers()); | ||
let currentTime = 0; | ||
function setTime(timeInMilliseconds: number) { | ||
@@ -88,4 +112,2 @@ jest | ||
); | ||
jest.advanceTimersByTime(Math.max(0, timeInMilliseconds - currentTime)); | ||
currentTime = timeInMilliseconds; | ||
} | ||
@@ -312,52 +334,62 @@ | ||
describe('RedisRateLimiter (`redis` client)', () => { | ||
let client: redis.RedisClient; | ||
beforeEach(() => { | ||
describe('node-redis client', () => { | ||
let client: redis.RedisClientType; | ||
beforeEach(async () => { | ||
client = redis.createClient(); | ||
await client.connect(); | ||
}); | ||
afterEach((cb) => client.quit(cb)); | ||
afterEach(() => client.quit()); | ||
sharedExamples( | ||
(opts) => | ||
new RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-redis', | ||
...opts, | ||
}), | ||
); | ||
}); | ||
describe('NodeRedisRateLimiter', () => { | ||
sharedExamples( | ||
(opts) => | ||
new NodeRedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-redis-1', | ||
...opts, | ||
}), | ||
); | ||
}); | ||
describe('RedisRateLimiter (`redis` client, `return_buffers` enabled)', () => { | ||
let client: redis.RedisClient; | ||
beforeEach(() => { | ||
client = redis.createClient({ return_buffers: true }); | ||
describe('RedisRateLimiter', () => { | ||
sharedExamples( | ||
(opts) => | ||
new RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-redis-2', | ||
...opts, | ||
}), | ||
); | ||
}); | ||
afterEach((cb) => client.quit(cb)); | ||
sharedExamples( | ||
(opts) => | ||
new RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-redis', | ||
...opts, | ||
}), | ||
); | ||
}); | ||
describe('RedisRateLimiter (`ioredis` client)', () => { | ||
let client: IORedis.Redis; | ||
describe('ioredis client', () => { | ||
let client: IORedis; | ||
beforeEach(() => { | ||
client = new IORedis(); | ||
}); | ||
afterEach((cb) => client.quit(cb)); | ||
afterEach(() => client.quit()); | ||
sharedExamples( | ||
(opts) => | ||
new RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-ioredis', | ||
...opts, | ||
}), | ||
); | ||
describe('IORedisRateLimiter', () => { | ||
sharedExamples( | ||
(opts) => | ||
new IORedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-ioredis-1', | ||
...opts, | ||
}), | ||
); | ||
}); | ||
describe('RedisRateLimiter', () => { | ||
sharedExamples( | ||
(opts) => | ||
new RedisRateLimiter({ | ||
client, | ||
namespace: 'rolling-rate-limiter-ioredis-2', | ||
...opts, | ||
}), | ||
); | ||
}); | ||
}); | ||
}); |
291
src/index.ts
@@ -111,5 +111,5 @@ import assert from 'assert'; | ||
const blockedDueToMinDifference = | ||
previousTimestamp != null && | ||
previousTimestamp != null && | ||
// Only performs the check for positive `minDifference` values. The `currentTimestamp` | ||
// created by `wouldLimit` may possibly be smaller than `previousTimestamp` in a distributed | ||
// created by `wouldLimit` may possibly be smaller than `previousTimestamp` in a distributed | ||
// environment. | ||
@@ -193,22 +193,31 @@ this.minDifference > 0 && | ||
/** | ||
* Minimal interface of a Redis client needed for algorithm. | ||
* Ideally, this would be `RedisClient | IORedisClient`, but that would force consumers of this | ||
* library to have `@types/redis` and `@types/ioredis` to be installed. | ||
* Wrapper class around a Redis client. | ||
* Exposes only the methods we need for the algorithm. | ||
* This papers over differences between `node-redis` and `ioredis`. | ||
*/ | ||
interface RedisClient { | ||
del(...args: Array<string>): unknown; | ||
multi(): RedisBatch; | ||
interface RedisClientWrapper { | ||
del(arg: string): unknown; | ||
multi(): RedisMultiWrapper; | ||
parseZRangeResult(result: unknown): Array<Microseconds>; | ||
} | ||
/** Minimal interface of a Redis batch command needed for algorithm. */ | ||
interface RedisBatch { | ||
zremrangebyscore(key: string, min: number, max: number): void; | ||
zadd(key: string, score: string | number, value: string): void; | ||
zrange(key: string, min: number, max: number, withScores: unknown): void; | ||
/** | ||
* Wrapper class around a Redis multi batch. | ||
* Exposes only the methods we need for the algorithm. | ||
* This papers over differences between `node-redis` and `ioredis`. | ||
*/ | ||
interface RedisMultiWrapper { | ||
zRemRangeByScore(key: string, min: number, max: number): void; | ||
zAdd(key: string, score: number, value: string): void; | ||
zRangeWithScores(key: string, min: number, max: number): void; | ||
expire(key: string, time: number): void; | ||
exec(cb: (err: Error | null, result: Array<unknown>) => void): void; | ||
exec(): Promise<Array<unknown>>; | ||
} | ||
interface RedisRateLimiterOptions extends RateLimiterOptions { | ||
client: RedisClient; | ||
/** | ||
* Generic options for constructing a Redis-backed rate limiter. | ||
* See `README.md` for more information. | ||
*/ | ||
interface RedisRateLimiterOptions<Client> extends RateLimiterOptions { | ||
client: Client; | ||
namespace: string; | ||
@@ -218,10 +227,14 @@ } | ||
/** | ||
* Rate limiter implementation that uses Redis for storage. | ||
* Abstract base class for Redis-based implementations. | ||
*/ | ||
export class RedisRateLimiter extends RateLimiter { | ||
client: RedisClient; | ||
abstract class BaseRedisRateLimiter extends RateLimiter { | ||
client: RedisClientWrapper; | ||
namespace: string; | ||
ttl: number; | ||
constructor({ client, namespace, ...baseOptions }: RedisRateLimiterOptions) { | ||
constructor({ | ||
client, | ||
namespace, | ||
...baseOptions | ||
}: RedisRateLimiterOptions<RedisClientWrapper>) { | ||
super(baseOptions); | ||
@@ -251,38 +264,228 @@ this.ttl = microsecondsToSeconds(this.interval); | ||
const batch = this.client.multi(); | ||
batch.zremrangebyscore(key, 0, clearBefore); | ||
batch.zRemRangeByScore(key, 0, clearBefore); | ||
if (addNewTimestamp) { | ||
batch.zadd(key, String(now), uuid()); | ||
batch.zAdd(key, now, uuid()); | ||
} | ||
batch.zrange(key, 0, -1, 'WITHSCORES'); | ||
batch.zRangeWithScores(key, 0, -1); | ||
batch.expire(key, this.ttl); | ||
return new Promise((resolve, reject) => { | ||
batch.exec((err, result) => { | ||
if (err) return reject(err); | ||
const results = await batch.exec(); | ||
const zRangeResult = addNewTimestamp ? results[2] : results[1]; | ||
return this.client.parseZRangeResult(zRangeResult); | ||
} | ||
} | ||
const zRangeOutput = (addNewTimestamp ? result[2] : result[1]) as Array<unknown>; | ||
const zRangeResult = this.getZRangeResult(zRangeOutput); | ||
const timestamps = this.extractTimestampsFromZRangeResult(zRangeResult); | ||
return resolve(timestamps); | ||
}); | ||
}); | ||
/** | ||
* Duck-typed `node-redis` client. We don't want to use the actual typing because that would | ||
* force users to install `node-redis` as a peer dependency. | ||
*/ | ||
interface NodeRedisClient { | ||
del(arg: string): unknown; | ||
multi(): NodeRedisMulti; | ||
} | ||
/** | ||
* Duck-typed `node-redis` multi object. We don't want to use the actual typing because that would | ||
* force users to install `node-redis` as a peer dependency. | ||
*/ | ||
interface NodeRedisMulti { | ||
zRemRangeByScore(key: string, min: number, max: number): void; | ||
zAdd(key: string, item: { score: number; value: string }): void; | ||
zRangeWithScores(key: string, min: number, max: number): void; | ||
expire(key: string, time: number): void; | ||
exec(): Promise<Array<unknown>>; | ||
} | ||
/** | ||
* Wrapper for `node-redis` client, proxying method calls to the underlying client. | ||
*/ | ||
class NodeRedisClientWrapper implements RedisClientWrapper { | ||
client: NodeRedisClient; | ||
constructor(client: NodeRedisClient) { | ||
this.client = client; | ||
} | ||
private getZRangeResult(zRangeOutput: Array<unknown>) { | ||
if (!Array.isArray(zRangeOutput[1])) { | ||
// Standard redis client, regular mode. | ||
return zRangeOutput as Array<string>; | ||
} else { | ||
// ioredis client. | ||
return zRangeOutput[1] as Array<string>; | ||
} | ||
del(arg: string) { | ||
return this.client.del(arg); | ||
} | ||
private extractTimestampsFromZRangeResult(zRangeResult: Array<string>) { | ||
// We only want the stored timestamps, which are the values, or the odd indexes. | ||
// Map to numbers because by default all returned values are strings. | ||
return zRangeResult.filter((e, i) => i % 2).map(Number) as Array<Microseconds>; | ||
multi() { | ||
return new NodeRedisMultiWrapper(this.client.multi()); | ||
} | ||
parseZRangeResult(result: unknown) { | ||
return ( | ||
result as Array<{ | ||
value: string; | ||
score: number; | ||
}> | ||
).map(({ score }) => score as Microseconds); | ||
} | ||
} | ||
/** | ||
* Wrapper for `node-redis` multi batch, proxying method calls to the underlying client. | ||
*/ | ||
class NodeRedisMultiWrapper implements RedisMultiWrapper { | ||
multi: NodeRedisMulti; | ||
constructor(multi: NodeRedisMulti) { | ||
this.multi = multi; | ||
} | ||
zRemRangeByScore(key: string, min: number, max: number) { | ||
this.multi.zRemRangeByScore(key, min, max); | ||
} | ||
zAdd(key: string, score: number, value: string) { | ||
this.multi.zAdd(key, { score: Number(score), value }); | ||
} | ||
zRangeWithScores(key: string, min: number, max: number) { | ||
this.multi.zRangeWithScores(key, min, max); | ||
} | ||
expire(key: string, time: number) { | ||
this.multi.expire(key, time); | ||
} | ||
async exec() { | ||
// TODO: ensure everything is a string? | ||
return this.multi.exec(); | ||
} | ||
} | ||
/** | ||
* Rate limiter backed by `node-redis`. | ||
*/ | ||
export class NodeRedisRateLimiter extends BaseRedisRateLimiter { | ||
constructor({ client, ...baseOptions }: RedisRateLimiterOptions<NodeRedisClient>) { | ||
super({ client: new NodeRedisClientWrapper(client), ...baseOptions }); | ||
} | ||
} | ||
/** | ||
* Duck-typed `ioredis` client. We don't want to use the actual typing because that would | ||
* force users to install `ioredis` as a peer dependency. | ||
*/ | ||
interface IORedisClient { | ||
del(arg: string): unknown; | ||
multi(): IORedisMulti; | ||
} | ||
/** | ||
* Duck-typed `ioredis` multi object. We don't want to use the actual typing because that would | ||
* force users to install `ioredis` as a peer dependency. | ||
*/ | ||
interface IORedisMulti { | ||
zremrangebyscore(key: string, min: number, max: number): void; | ||
zadd(key: string, score: number, value: string): void; | ||
zrange(key: string, min: number, max: number, withScores: 'WITHSCORES'): void; | ||
expire(key: string, time: number): void; | ||
exec(): Promise<Array<[error: Error | null, result: unknown]> | null>; | ||
} | ||
/** | ||
* Wrapper for `ioredis` client, proxying method calls to the underlying client. | ||
*/ | ||
class IORedisClientWrapper implements RedisClientWrapper { | ||
client: IORedisClient; | ||
constructor(client: IORedisClient) { | ||
this.client = client; | ||
} | ||
del(arg: string) { | ||
return this.client.del(arg); | ||
} | ||
multi() { | ||
return new IORedisMultiWrapper(this.client.multi()); | ||
} | ||
parseZRangeResult(result: unknown) { | ||
const valuesAndScores = (result as [null, Array<string>])[1]; | ||
return valuesAndScores.filter((e, i) => i % 2).map(Number) as Array<Microseconds>; | ||
} | ||
} | ||
/** | ||
* Wrapper for `ioredis` multi batch, proxying method calls to the underlying client. | ||
*/ | ||
class IORedisMultiWrapper implements RedisMultiWrapper { | ||
multi: IORedisMulti; | ||
constructor(multi: IORedisMulti) { | ||
this.multi = multi; | ||
} | ||
zRemRangeByScore(key: string, min: number, max: number) { | ||
this.multi.zremrangebyscore(key, min, max); | ||
} | ||
zAdd(key: string, score: number, value: string) { | ||
this.multi.zadd(key, score, value); | ||
} | ||
zRangeWithScores(key: string, min: number, max: number) { | ||
this.multi.zrange(key, min, max, 'WITHSCORES'); | ||
} | ||
expire(key: string, time: number) { | ||
this.multi.expire(key, time); | ||
} | ||
async exec() { | ||
return (await this.multi.exec()) ?? []; | ||
} | ||
} | ||
/** | ||
* Rate limiter backed by `ioredis`. | ||
*/ | ||
export class IORedisRateLimiter extends BaseRedisRateLimiter { | ||
constructor({ client, ...baseOptions }: RedisRateLimiterOptions<IORedisClient>) { | ||
super({ client: new IORedisClientWrapper(client), ...baseOptions }); | ||
} | ||
} | ||
type RedisClientType = 'node-redis' | 'ioredis'; | ||
/** | ||
* Rate limiter backed by either `node-redis` or `ioredis`. | ||
* Uses duck-typing to determine which client is being used. | ||
*/ | ||
export class RedisRateLimiter extends BaseRedisRateLimiter { | ||
/** | ||
* Given an unknown object, determine what type of redis client it is. | ||
* Used by the constructor of this class. | ||
*/ | ||
public static determineRedisClientType(client: any): RedisClientType | null { | ||
if ('zRemRangeByScore' in client && 'ZREMRANGEBYSCORE' in client) return 'node-redis'; | ||
if ('zremrangebyscore' in client) return 'ioredis'; | ||
return null; | ||
} | ||
public readonly detectedClientType: RedisClientType; | ||
constructor({ client, ...baseOptions }: RedisRateLimiterOptions<any>) { | ||
const clientType = RedisRateLimiter.determineRedisClientType(client); | ||
if (clientType == null) { | ||
throw new Error('Could not detect redis client type'); | ||
} else if (clientType === 'node-redis') { | ||
super({ | ||
client: new NodeRedisClientWrapper(client as NodeRedisClient), | ||
...baseOptions, | ||
}); | ||
} else { | ||
super({ | ||
client: new IORedisClientWrapper(client as IORedisClient), | ||
...baseOptions, | ||
}); | ||
} | ||
this.detectedClientType = clientType; | ||
} | ||
} | ||
export function getCurrentMicroseconds() { | ||
@@ -289,0 +492,0 @@ return microtime.now() as Microseconds; |
Sorry, the diff of this file is not supported yet
174650
144.93%11
-15.38%11
10%1623
36.96%98
3.16%+ Added
- Removed
Updated