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.
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,
});
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();
}
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`)
};
Creates a new RollingLimit instance. See types above.
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
.
Synchronously returns a fake-but-complete response object, with the supplied max for a limit.