ratelimit.js
Version:
A NodeJS library for efficient rate limiting using sliding windows stored in Redis.
192 lines (147 loc) • 5.65 kB
text/coffeescript
EvalSha = require 'redis-evalsha'
_ = require 'underscore'
async = require 'async'
fs = require 'fs'
module.exports = class RateLimit
@DEFAULT_PREFIX: 'ratelimit'
# Note: 1 is returned for a normal rate limited action, 2 is returned for a
# blacklisted action. Must sync with return codes in lua/check_limit.lua
@DENIED_NUMS: [1, 2]
constructor: (@redisClient, rules, @options = {}) ->
# Support passing the prefix directly as the third parameter.
if _.isString @options
@options =
prefix: @options
# Enforce default prefix if none is defined. Note: use ?= to allow users
# to specify an empty prefix. If you are using a redis library that
# automatically prefixes keys then you might specify a blank prefix.
@options.prefix ?= @constructor.DEFAULT_PREFIX
# Used if the redis client passed in support transparent prefixing (like
# ioredis). This is used for the white/blacklist keys passed to the Lua
# scripts.
@options.clientPrefix ?= false
@checkFn = 'check_rate_limit'
@checkIncrFn = 'check_incr_rate_limit'
@eval = new EvalSha @redisClient
@eval.add @checkFn, @checkLimitScript()
@eval.add @checkIncrFn, @checkLimitIncrScript()
@rules = @convertRules rules
readLua: (filename) ->
fs.readFileSync "#{__dirname}/../lua/#{filename}.lua", 'utf8'
checkLimitScript: ->
[
@readLua 'unpack_args'
@readLua 'check_whitelist_blacklist'
@readLua 'check_limit'
'return 0'
].join '\n'
checkLimitIncrScript: ->
[
@readLua 'unpack_args'
@readLua 'check_whitelist_blacklist'
@readLua 'check_limit'
@readLua 'check_incr_limit'
].join '\n'
convertRules: (rules) ->
for {interval, limit, precision} in rules when interval and limit
_.compact [interval, limit, precision]
prefixKey: (key, force = false) ->
parts = [key]
# Support prefixing with an optional `force` argument, but omit prefix by
# default if the client library supports transparent prefixing.
parts.unshift @options.prefix if force or not @options.clientPrefix
# The compact handles a falsy prefix
_.compact(parts).join ':'
whitelistKey: ->
@prefixKey 'whitelist', true
blacklistKey: ->
@prefixKey 'blacklist', true
scriptArgs: (keys, weight = 1) ->
# Keys has to be a list
adjustedKeys = _.chain([keys])
.flatten()
.compact()
.filter (key) ->
_.isString(key) and key.length
.map (key) =>
@prefixKey key
.value()
throw new Error "Bad keys: #{keys}" unless adjustedKeys.length
rules = JSON.stringify @rules
ts = Math.floor Date.now() / 1000
weight = Math.max weight, 1
[adjustedKeys, [rules, ts, weight, @whitelistKey(), @blacklistKey()]]
check: (keys, callback) ->
try
[keys, args] = @scriptArgs keys
catch err
return callback err
@eval.exec @checkFn, keys, args, (err, result) =>
callback err, result in @constructor.DENIED_NUMS
incr: (keys, weight, callback) ->
# Weight is optional.
[weight, callback] = [1, weight] if arguments.length is 2
try
[keys, args] = @scriptArgs keys, weight
catch err
return callback err
@eval.exec @checkIncrFn, keys, args, (err, result) =>
callback err, result in @constructor.DENIED_NUMS
keys: (callback) ->
@redisClient.keys @prefixKey('*'), (err, results) =>
return callback err if err
re = new RegExp @prefixKey '(.+)'
keys = (re.exec(key)[1] for key in results)
callback null, keys
violatedRules: (keys, callback) ->
checkKey = (key, callback) =>
checkRule = (rule, callback) =>
# Note: this mirrors precision computation in `check_limit.lua`
# on lines 7 and 8 and count key construction on line 16
[interval, limit, precision] = rule
precision = Math.min (precision ? interval), interval
countKey = "#{interval}:#{precision}:"
@redisClient.hget @prefixKey(key), countKey, (err, count = -1) ->
return callback() unless count >= limit
callback null, {interval, limit}
async.map @rules, checkRule, (err, violatedRules) ->
callback err, _.compact violatedRules
async.concat _.flatten([keys]), checkKey, callback
limitedKeys: (callback) ->
@keys (err, keys) =>
return callback err if err
fn = (key, callback) =>
@check key, (err, limited) ->
callback limited
async.filter keys, fn, (results) ->
callback null, results
whitelist: (keys, callback) ->
whitelist = (key, callback) =>
key = @prefixKey key
async.series [
(callback) =>
@redisClient.srem @blacklistKey(), key, callback
(callback) =>
@redisClient.sadd @whitelistKey(), key, callback
], callback
async.each keys, whitelist, callback
unwhitelist: (keys, callback) ->
unwhitelist = (key, callback) =>
key = @prefixKey key
@redisClient.srem @whitelistKey(), key, callback
async.each keys, unwhitelist, callback
blacklist: (keys, callback) ->
blacklist = (key, callback) =>
key = @prefixKey key
async.series [
(callback) =>
@redisClient.srem @whitelistKey(), key, callback
(callback) =>
@redisClient.sadd @blacklistKey(), key, callback
], callback
async.each keys, blacklist, callback
unblacklist: (keys, callback) ->
unblacklist = (key, callback) =>
key = @prefixKey key
@redisClient.srem @blacklistKey(), key, callback
async.each keys, unblacklist, callback