You're Invited: Meet the Socket team at BSidesSF and RSAC - April 27 - May 1.RSVP
Socket
Sign inDemoInstall
Socket

rolling-rate-limiter

Package Overview
Dependencies
Maintainers
1
Versions
32
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

rolling-rate-limiter - npm Package Compare versions

Comparing version

to
0.2.12

21

lib/index.d.ts
/// <reference types="node" />
export declare type Id = number | string;
export declare type Seconds = number;
export declare type Milliseconds = number;
export declare type Microseconds = number;
export declare type Seconds = number & {
__brand: 'seconds';
};
export declare type Milliseconds = number & {
__brand: 'milliseconds';
};
export declare type Microseconds = number & {
__brand: 'microseconds';
};
/**

@@ -30,5 +36,5 @@ * Generic options for constructing any rate limiter.

export declare class RateLimiter {
interval: number;
interval: Microseconds;
maxInInterval: number;
minDifference: number;
minDifference: Microseconds;
constructor({ interval, maxInInterval, minDifference }: RateLimiterOptions);

@@ -75,3 +81,3 @@ /**

clear(id: Id): Promise<void>;
protected getTimestamps(id: Id, addNewTimestamp: boolean): Promise<Array<Microseconds>>;
protected getTimestamps(id: Id, addNewTimestamp: boolean): Promise<Microseconds[]>;
}

@@ -113,5 +119,6 @@ /**

}
export declare function getCurrentMicroseconds(): Microseconds;
export declare function millisecondsToMicroseconds(milliseconds: Milliseconds): Microseconds;
export declare function microsecondsToMilliseconds(microseconds: Microseconds): Milliseconds;
export declare function microsecondsToTTLSeconds(microseconds: Microseconds): Seconds;
export declare function microsecondsToSeconds(microseconds: Microseconds): Seconds;
export {};

@@ -6,5 +6,4 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
exports.microsecondsToTTLSeconds = exports.microsecondsToMilliseconds = exports.millisecondsToMicroseconds = exports.RedisRateLimiter = exports.InMemoryRateLimiter = exports.RateLimiter = void 0;
exports.microsecondsToSeconds = exports.microsecondsToMilliseconds = exports.millisecondsToMicroseconds = exports.getCurrentMicroseconds = exports.RedisRateLimiter = exports.InMemoryRateLimiter = exports.RateLimiter = void 0;
const assert_1 = __importDefault(require("assert"));
const microtime_nodejs_1 = require("microtime-nodejs");
const uuid_1 = require("uuid");

@@ -16,5 +15,5 @@ /**

constructor({ interval, maxInInterval, minDifference = 0 }) {
assert_1.default(interval > 0, 'Must pass a positive integer for `options.interval`');
assert_1.default(maxInInterval > 0, 'Must pass a positive integer for `options.maxInInterval`');
assert_1.default(minDifference >= 0, '`options.minDifference` cannot be negative');
(0, assert_1.default)(interval > 0, 'Must pass a positive integer for `options.interval`');
(0, assert_1.default)(maxInInterval > 0, 'Must pass a positive integer for `options.maxInInterval`');
(0, assert_1.default)(minDifference >= 0, '`options.minDifference` cannot be negative');
this.interval = millisecondsToMicroseconds(interval);

@@ -36,3 +35,3 @@ this.maxInInterval = maxInInterval;

async wouldLimitWithInfo(id) {
const currentTimestamp = microtime_nodejs_1.now();
const currentTimestamp = getCurrentMicroseconds();
const existingTimestamps = await this.getTimestamps(id, false);

@@ -81,7 +80,8 @@ return this.calculateInfo([...existingTimestamps, currentTimestamp]);

// until the interval is not full anymore.
const microsecondsUntilAllowed = Math.max(this.minDifference, numTimestamps >= this.maxInInterval
const microsecondsUntilUnblocked = numTimestamps >= this.maxInInterval
? timestamps[Math.max(0, numTimestamps - this.maxInInterval)] -
currentTimestamp +
this.interval
: 0);
: 0;
const microsecondsUntilAllowed = Math.max(this.minDifference, microsecondsUntilUnblocked);
return {

@@ -115,3 +115,3 @@ blocked,

async getTimestamps(id, addNewTimestamp) {
const currentTimestamp = microtime_nodejs_1.now();
const currentTimestamp = getCurrentMicroseconds();
// Update the stored timestamps, including filtering out old ones, and adding the new one.

@@ -143,3 +143,3 @@ const clearBefore = currentTimestamp - this.interval;

super(baseOptions);
this.ttl = microsecondsToTTLSeconds(this.interval);
this.ttl = microsecondsToSeconds(this.interval);
this.client = client;

@@ -156,3 +156,3 @@ this.namespace = namespace;

async getTimestamps(id, addNewTimestamp) {
const now = microtime_nodejs_1.now();
const now = getCurrentMicroseconds();
const key = this.makeKey(id);

@@ -163,3 +163,3 @@ const clearBefore = now - this.interval;

if (addNewTimestamp) {
batch.zadd(key, String(now), uuid_1.v4());
batch.zadd(key, String(now), (0, uuid_1.v4)());
}

@@ -196,4 +196,9 @@ batch.zrange(key, 0, -1, 'WITHSCORES');

exports.RedisRateLimiter = RedisRateLimiter;
function getCurrentMicroseconds() {
const hr = process.hrtime();
return (hr[0] * 1e6 + Math.ceil(hr[1] / 1000));
}
exports.getCurrentMicroseconds = getCurrentMicroseconds;
function millisecondsToMicroseconds(milliseconds) {
return 1000 * milliseconds;
return (1000 * milliseconds);
}

@@ -205,5 +210,5 @@ exports.millisecondsToMicroseconds = millisecondsToMicroseconds;

exports.microsecondsToMilliseconds = microsecondsToMilliseconds;
function microsecondsToTTLSeconds(microseconds) {
function microsecondsToSeconds(microseconds) {
return Math.ceil(microseconds / 1000 / 1000);
}
exports.microsecondsToTTLSeconds = microsecondsToTTLSeconds;
exports.microsecondsToSeconds = microsecondsToSeconds;

@@ -6,4 +6,4 @@ "use strict";

Object.defineProperty(exports, "__esModule", { value: true });
const process_1 = __importDefault(require("process"));
const ioredis_1 = __importDefault(require("ioredis"));
const microtime_nodejs_1 = __importDefault(require("microtime-nodejs"));
const redis_1 = __importDefault(require("redis"));

@@ -66,8 +66,11 @@ const _1 = require(".");

let currentTime = 0;
function setTime(newTime) {
function setTime(timeInMilliseconds) {
jest
.spyOn(microtime_nodejs_1.default, 'now')
.mockImplementation(() => _1.millisecondsToMicroseconds(newTime));
jest.advanceTimersByTime(Math.max(0, newTime - currentTime));
currentTime = newTime;
.spyOn(process_1.default, 'hrtime')
.mockImplementation(() => [
Math.floor(timeInMilliseconds / 1e3),
(timeInMilliseconds % 1e3) * 1e6,
]);
jest.advanceTimersByTime(Math.max(0, timeInMilliseconds - currentTime));
currentTime = timeInMilliseconds;
}

@@ -74,0 +77,0 @@ function sharedExamples(_createLimiter) {

{
"name": "rolling-rate-limiter",
"version": "0.2.11",
"version": "0.2.12",
"description": "Rate limiter that supports a rolling window, either in-memory or backed by Redis",

@@ -41,3 +41,2 @@ "main": "lib/index.js",

"dependencies": {
"microtime-nodejs": "~1.0.0",
"uuid": "^8.3.0"

@@ -51,3 +50,2 @@ },

"@types/uuid": "^8.3.0",
"async": "~3.2.0",
"eslint": "^7.25.0",

@@ -57,3 +55,2 @@ "eslint-config-peterkhayes": "^4.0.0",

"jest": "^26.6.3",
"lodash": "^4.17.21",
"redis": "^3.1.2",

@@ -60,0 +57,0 @@ "ts-jest": "^26.5.5",

# Rolling Rate Limiter
![build status](https://github.com/peterkhayes/rolling-rate-limiter/workflows/CI/badge.svg)
This is an implementation of a rate limiter in node.js that allows for rate limiting with a rolling window. It can use either in-memory storage or Redis as a backend. If Redis is used, multiple rate limiters can share one instance with different namespaces, and multiple processes can share rate limiter state safely.
This is an implementation of a rate limiter in node.js that allows for rate limiting with a rolling window. It can use either in-memory storage or Redis as a backend. If Redis is used, multiple rate limiters can share one instance with different namespaces, and multiple processes can share rate limiter state safely.
This means that if a user is allowed 5 actions per 60 seconds, any action will be blocked if 5 actions have already occured in the preceeding 60 seconds, without any set points at which this interval resets. This contrasts with some other rate limiter implementations, in which a user could make 5 requests at 0:59 and another 5 requests at 1:01.
This means that if a user is allowed 5 actions per 60 seconds, any action will be blocked if 5 actions have already occured in the preceeding 60 seconds, without any set points at which this interval resets. This contrasts with some other rate limiter implementations, in which a user could make 5 requests at 0:59 and another 5 requests at 1:01.
**Important Note**:
As a consequence of the way the Redis algorithm works, if an action is blocked, it is still "counted". This means that if a user is continually attempting actions more quickly than the allowed rate, __all__ of their actions will be blocked until they pause or slow their requests.
As a consequence of the way the Redis algorithm works, if an action is blocked, it is still "counted". This means that if a user is continually attempting actions more quickly than the allowed rate, **all** of their actions will be blocked until they pause or slow their requests.

@@ -14,20 +15,22 @@ This behavior is somewhat counterintuitive, but it's the only way that I have found that uses an atomic `MULTI` set of commands for Redis. Without this, race conditions would be possible. [See more below.](#method-of-operation).

## Upgrading from 0.1
Version 0.2 was released August 31 2020. The method of operation remains the same, but the API has changed. A short summary of the changes:
* Library was rewritten in Typescript.
* Rate limiters are now instances of a `RateLimiter` class.
* Methods now use promises instead of callbacks.
* A `wouldLimit` method is now available to see if an action would be blocked, without actually "counting" it as an action.
* `limitWithInfo` and `wouldLimitWithInfo` methods are available to return more information about how and why an action was blocked or not blocked.
* Tests were rewritten in Jest, and run on both `redis` and `ioredis` clients.
- Library was rewritten in Typescript.
- Rate limiters are now instances of a `RateLimiter` class.
- Methods now use promises instead of callbacks.
- A `wouldLimit` method is now available to see if an action would be blocked, without actually "counting" it as an action.
- `limitWithInfo` and `wouldLimitWithInfo` methods are available to return more information about how and why an action was blocked or not blocked.
- Tests were rewritten in Jest, and run on both `redis` and `ioredis` clients.
## Quick start
Basic use in an Express application.
```javascript
const { RedisRateLimiter } = require('rolling-rate-limiter');
const { RedisRateLimiter } = require("rolling-rate-limiter");
const limiter = new RedisRateLimiter({
client: redisClient, // client instance from `redis` or `ioredis`
namespace: 'rate-limiter', // prefix for redis keys
namespace: "rate-limiter", // prefix for redis keys
interval: 60000, // milliseconds

@@ -37,3 +40,3 @@ maxInInterval: 5,

app.use(function(req, res, next) {
app.use(function (req, res, next) {
limiter.limit(req.ipAddress).then((wasBlocked) => {

@@ -45,3 +48,3 @@ if (wasBlocked) {

}
})
});
});

@@ -51,40 +54,46 @@ ```

## Available limiters
* `RedisRateLimiter` - Stores state in Redis. Can use `redis` or `ioredis` clients.
* `InMemoryRateLimiter` - Stores state in memory. Useful in testing or outside of web servers.
- `RedisRateLimiter` - Stores state in Redis. Can use `redis` or `ioredis` clients.
- `InMemoryRateLimiter` - Stores state in memory. Useful in testing or outside of web servers.
## Configuration options
* `interval: number` - The length of the rate limiter's interval, in milliseconds. For example, if you want a user to be able to perform 5 actions per minute, this should be `60000`.
* `maxInInterval: number` - The number of actions allowed in each interval. For example, in the scenario above, this would be `5`
* `minDifference?: number` - Optional. The minimum time allowed between consecutive actions, in milliseconds.
* `client: Client` (Redis only) - The Redis client to use.
* `namespace: string` (Redis only) - A string to prepend to all keys to prevent conflicts with other code using Redis.
- `interval: number` - The length of the rate limiter's interval, in milliseconds. For example, if you want a user to be able to perform 5 actions per minute, this should be `60000`.
- `maxInInterval: number` - The number of actions allowed in each interval. For example, in the scenario above, this would be `5`
- `minDifference?: number` - Optional. The minimum time allowed between consecutive actions, in milliseconds.
- `client: Client` (Redis only) - The Redis client to use.
- `namespace: string` (Redis only) - A string to prepend to all keys to prevent conflicts with other code using Redis.
## Instance Methods
All methods take an `Id`, which should be of type `number | string`. Commonly, this will be a user's id.
* `limit(id: Id): Promise<boolean>` - Attempt to perform an action. Returns `false` if the action should be allowed, and `true` if the action should be blocked.
* `wouldLimit(id: Id): Promise<boolean>` - Return what would happen if an action were attempted. Returns `false` if an action would not have been blocked, and `true` if an action would have been blocked. Does not "count" as an action.
* `limitWithInfo(id: Id): Promise<RateLimitInfo>` - Attempt to perform an action. Returns whether the action should be blocked, as well as additional information about why it was blocked and how long the user must wait.
* `wouldLimitWithInfo(id: Id): Promise<RateLimitInfo>` - Returns info about what would happened if an action were attempted and why. Does not "count" as an action.
- `limit(id: Id): Promise<boolean>` - Attempt to perform an action. Returns `false` if the action should be allowed, and `true` if the action should be blocked.
- `wouldLimit(id: Id): Promise<boolean>` - Return what would happen if an action were attempted. Returns `false` if an action would not have been blocked, and `true` if an action would have been blocked. Does not "count" as an action.
- `limitWithInfo(id: Id): Promise<RateLimitInfo>` - Attempt to perform an action. Returns whether the action should be blocked, as well as additional information about why it was blocked and how long the user must wait.
- `wouldLimitWithInfo(id: Id): Promise<RateLimitInfo>` - Returns info about what would happened if an action were attempted and why. Does not "count" as an action.
`RateLimitInfo` contains the following properties:
* `blocked: boolean` - Whether the action was blocked (or would have been blocked).
* `blockedDueToCount: boolean` - Whether the action was blocked (or would have been blocked) because of the `interval` and `maxInInterval` properties.
* `blockedDueToMinDifference: boolean` - Whether the action was blocked (or would have been blocked) because of the `minDistance` property.
* `millisecondsUntilAllowed: number` - The number of milliseconds the user must wait until they can make another action. If another action would immediately be permitted, this is `0`.
* `actionsRemaining: number` - The number of actions a user has left within the interval. Does not account for `minDifference`.
- `blocked: boolean` - Whether the action was blocked (or would have been blocked).
- `blockedDueToCount: boolean` - Whether the action was blocked (or would have been blocked) because of the `interval` and `maxInInterval` properties.
- `blockedDueToMinDifference: boolean` - Whether the action was blocked (or would have been blocked) because of the `minDistance` property.
- `millisecondsUntilAllowed: number` - The number of milliseconds the user must wait until they can make another action. If another action would immediately be permitted, this is `0`.
- `actionsRemaining: number` - The number of actions a user has left within the interval. Does not account for `minDifference`.
## Method of operation
* Each identifier/user corresponds to a _sorted set_ data structure. The keys and values are both equal to the (microsecond) times at which actions were attempted, allowing easy manipulation of this list.
* When a new action comes in for a user, all elements in the set that occurred earlier than (current time - interval) are dropped from the set.
* If the number of elements in the set is still greater than the maximum, the current action is blocked.
* If a minimum difference has been set and the most recent previous element is too close to the current time, the current action is blocked.
* The current action is then added to the set.
* _Note_: if an action is blocked, it is still added to the set. This means that if a user is continually attempting actions more quickly than the allowed rate, _all_ of their actions will be blocked until they pause or slow their requests.
* If the limiter uses a redis instance, the keys are prefixed with namespace, allowing a single redis instance to support separate rate limiters.
* All redis operations for a single rate-limit check/update are performed as an atomic transaction, allowing rate limiters running on separate processes or machines to share state safely.
- Each identifier/user corresponds to a _sorted set_ data structure. The keys and values are both equal to the (microsecond) times at which actions were attempted, allowing easy manipulation of this list.
- When a new action comes in for a user, all elements in the set that occurred earlier than (current time - interval) are dropped from the set.
- If the number of elements in the set is still greater than the maximum, the current action is blocked.
- If a minimum difference has been set and the most recent previous element is too close to the current time, the current action is blocked.
- The current action is then added to the set.
- _Note_: if an action is blocked, it is still added to the set. This means that if a user is continually attempting actions more quickly than the allowed rate, _all_ of their actions will be blocked until they pause or slow their requests.
- If the limiter uses a redis instance, the keys are prefixed with namespace, allowing a single redis instance to support separate rate limiters.
- All redis operations for a single rate-limit check/update are performed as an atomic transaction, allowing rate limiters running on separate processes or machines to share state safely.
## Local development
### Installation
Install dependencies with `yarn`.

@@ -95,6 +104,7 @@

### Testing
* `yarn ci`: Runs the CI build, including linting, type checking, and tests. Requires [act](https://github.com/nektos/act) to run GitHub actions locally.
* `yarn lint`: Runs ESLint.
* `yarn test`: Runs Jest.
* `yarn typecheck`: Runs TypeScript, without emitting output.
* `yarn build`: Runs TypeScript and outputs to `./lib`.
- `yarn ci`: Runs the CI build, including linting, type checking, and tests. Requires [act](https://github.com/nektos/act) to run GitHub actions locally.
- `yarn lint`: Runs ESLint.
- `yarn test`: Runs Jest.
- `yarn typecheck`: Runs TypeScript, without emitting output.
- `yarn build`: Runs TypeScript and outputs to `./lib`.

@@ -0,3 +1,3 @@

import process from 'process';
import IORedis from 'ioredis';
import microtime from 'microtime-nodejs';
import redis from 'redis';

@@ -10,4 +10,2 @@

RedisRateLimiter,
millisecondsToMicroseconds,
Milliseconds,
} from '.';

@@ -82,8 +80,11 @@

let currentTime = 0;
function setTime(newTime: Milliseconds) {
function setTime(timeInMilliseconds: number) {
jest
.spyOn(microtime, 'now')
.mockImplementation(() => millisecondsToMicroseconds(newTime));
jest.advanceTimersByTime(Math.max(0, newTime - currentTime));
currentTime = newTime;
.spyOn(process, 'hrtime')
.mockImplementation(() => [
Math.floor(timeInMilliseconds / 1e3),
(timeInMilliseconds % 1e3) * 1e6,
]);
jest.advanceTimersByTime(Math.max(0, timeInMilliseconds - currentTime));
currentTime = timeInMilliseconds;
}

@@ -90,0 +91,0 @@

import assert from 'assert';
import { now as getCurrentMicroseconds } from 'microtime-nodejs';
import { v4 as uuid } from 'uuid';
export type Id = number | string;
export type Seconds = number;
export type Milliseconds = number;
export type Microseconds = number;
export type Seconds = number & { __brand: 'seconds' };
export type Milliseconds = number & { __brand: 'milliseconds' };
export type Microseconds = number & { __brand: 'microseconds' };

@@ -36,5 +35,5 @@ /**

export class RateLimiter {
interval: number;
interval: Microseconds;
maxInInterval: number;
minDifference: number;
minDifference: Microseconds;

@@ -46,5 +45,5 @@ constructor({ interval, maxInInterval, minDifference = 0 }: RateLimiterOptions) {

this.interval = millisecondsToMicroseconds(interval);
this.interval = millisecondsToMicroseconds(interval as Milliseconds);
this.maxInInterval = maxInInterval;
this.minDifference = millisecondsToMicroseconds(minDifference);
this.minDifference = millisecondsToMicroseconds(minDifference as Milliseconds);
}

@@ -121,10 +120,13 @@

// until the interval is not full anymore.
const microsecondsUntilUnblocked =
numTimestamps >= this.maxInInterval
? (timestamps[Math.max(0, numTimestamps - this.maxInInterval)] as number) -
(currentTimestamp as number) +
(this.interval as number)
: 0;
const microsecondsUntilAllowed = Math.max(
this.minDifference,
numTimestamps >= this.maxInInterval
? timestamps[Math.max(0, numTimestamps - this.maxInInterval)] -
currentTimestamp +
this.interval
: 0,
);
microsecondsUntilUnblocked,
) as Microseconds;

@@ -163,6 +165,3 @@ return {

protected async getTimestamps(
id: Id,
addNewTimestamp: boolean,
): Promise<Array<Microseconds>> {
protected async getTimestamps(id: Id, addNewTimestamp: boolean) {
const currentTimestamp = getCurrentMicroseconds();

@@ -187,3 +186,3 @@ // Update the stored timestamps, including filtering out old ones, and adding the new one.

this.storage[id] = storedTimestamps;
return storedTimestamps;
return storedTimestamps as Array<Microseconds>;
}

@@ -226,3 +225,3 @@ }

super(baseOptions);
this.ttl = microsecondsToTTLSeconds(this.interval);
this.ttl = microsecondsToSeconds(this.interval);
this.client = client;

@@ -279,19 +278,24 @@ this.namespace = namespace;

private extractTimestampsFromZRangeResult(zRangeResult: Array<string>): Array<number> {
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);
return zRangeResult.filter((e, i) => i % 2).map(Number) as Array<Microseconds>;
}
}
export function millisecondsToMicroseconds(milliseconds: Milliseconds): Microseconds {
return 1000 * milliseconds;
export function getCurrentMicroseconds() {
const hr = process.hrtime();
return (hr[0] * 1e6 + Math.ceil(hr[1] / 1000)) as Microseconds;
}
export function microsecondsToMilliseconds(microseconds: Microseconds): Milliseconds {
return Math.ceil(microseconds / 1000);
export function millisecondsToMicroseconds(milliseconds: Milliseconds) {
return (1000 * milliseconds) as Microseconds;
}
export function microsecondsToTTLSeconds(microseconds: Microseconds): Seconds {
return Math.ceil(microseconds / 1000 / 1000);
export function microsecondsToMilliseconds(microseconds: Microseconds) {
return Math.ceil(microseconds / 1000) as Milliseconds;
}
export function microsecondsToSeconds(microseconds: Microseconds) {
return Math.ceil(microseconds / 1000 / 1000) as Seconds;
}

Sorry, the diff of this file is not supported yet