Skip to content

BitMEX/node-redis-token-bucket-ratelimiter

Repository files navigation

node-redis-token-bucket-ratelimiter

A rolling rate limit using Redis. Original idea from Peter Hayes. Uses a lua script for atomic operations and to prevent blocked actions from substracting from the bucket.

Compatible with ioredis (including in Redis Cluster mode) and node-redis client.

Usage

const RollingLimit = require('redis-token-bucket-ratelimiter');
const Redis = require('ioredis');
const redisClient = new Redis({port});
const myAppVersion = require('./package.json').version;
const defaultLimiter = new RollingLimit({
  interval: 5000,
  limit: 3,
  redis: redisClient,
  prefix: `${myAppVersion}:`,
  force: false,
});

How It Works

Token Bucket ratelimiters can be described as a bucket within which "tokens" are added at a constant rate. Every time a request is made, a token is removed from the bucket. If the bucket is empty, the request is rejected.

For instance, one might set a 60/1min request limit by instantiating a limiter like so:

const requestLimiter = new RollingLimit({
  interval: 60000,
  limit: 60,
  redis: RedisClient
});

Then use it as middleware on each request:

async function rateLimitMiddleware(req, res, next) {
  const id = getUserId(req);
  const limit = await requestLimiter.use(id);

  // Your max tokens
  res.set('X-RateLimit-Limit', String(limit.limit));
  // Remaining tokens; this continually refills
  res.set('X-RateLimit-Remaining', String(limit.remaining));
  // The time at which it's valid to do the same request again; this is almost always now()
  const retrySec = Math.ceil(limit.retryDelta / 1000);
  res.set(
    'X-RateLimit-Reset',
    String(Math.ceil(Date.now() / 1000) + retrySec)
  );

  if (limit.rejected) {
    res.set('Retry-After', String(retrySec));
    res.status(429).json({
      error: {
        message: `Rate limit exceeded, retry in ${retrySec} seconds.`,
        name: 'RateLimitError',
      },
    });
    return;
  }
  next();
}

RollingLimit Types and Methods

Types

type RollingLimiterOptions = {
  // millisecond duration for the `limit`
  interval: number,
  // maximum of allowed uses in one rolling `interval`
  limit: number,
  // an ioredis or node-redis client instance
  redis: Object,
  // (optional) A string to prepend before `id` for each key
  // Useful for avoiding collisions between applications or versions of an application.
  // A trailing colon is optional and will be added if not present
  prefix?: string,
  // (optional) a boolean to force an accept, but draining the bucket if necessary
  // This allows the limiter to go negative. Use for instances where an action must be allowed,
  // but you still want to deduct from the limit.
  force?: boolean,
};

type RollingLimiterResult = {
  limit: number,      // the limit passed into `RollingLimiterOptions` on this invocation
  remaining: number,  // the number of tokens left in the bucket. Can be negative with `force`
  rejected: boolean,  // `true` if the request was rejected, `false` otherwise
  retryDelta: number, // if rejected, milliseconds to wait before making the next request
  forced: boolean,    // if `true`, `force` was on (see `RollingLimiterOptions`)
};

Methods

limiter = new RollingLimit(options: RollingLimiterOptions)

Creates a new RollingLimit instance. See types above.

limiter.use(id: string): Promise<RateLimitResponse>

limiter.use(id: string, amount?: number): Promise<RateLimitResponse>

Takes a token from the limit's bucket for id in redis and returns a promise with a RollingLimiterResult object.

If you want to get the count of tokens left, send in an amount of 0.

static RateLimiter.stubLimit(max): RateLimitResponse

Synchronously returns a fake-but-complete response object, with the supplied max for a limit.