├── .gitignore ├── .npmignore ├── lib ├── index.js ├── middleware.js ├── rate-limiter.js └── options.js ├── .github └── dependabot.yml ├── .travis.yml ├── test ├── reset.js ├── rate-limiter.ioredis.spec.js ├── rate-limiter.spec.js ├── options.spec.js └── middleware.spec.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dump.rdb 3 | .DS_Store 4 | .idea -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .npmignore 3 | .travis.yml 4 | test 5 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 2 | exports.create = require('./rate-limiter'); 3 | exports.middleware = require('./middleware'); 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | timezone: Australia/Sydney 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "12" 5 | - "14" 6 | services: 7 | - redis-server 8 | script: 9 | - npm run lint 10 | - npm test 11 | jobs: 12 | include: 13 | - stage: npm release 14 | script: echo "Deploying to npm ..." 15 | deploy: 16 | provider: npm 17 | email: $NPM_EMAIL 18 | api_key: $NPM_TOKEN 19 | skip_cleanup: true 20 | on: 21 | tags: true 22 | branch: master 23 | -------------------------------------------------------------------------------- /lib/middleware.js: -------------------------------------------------------------------------------- 1 | var rateLimiter = require('./rate-limiter'); 2 | 3 | module.exports = function(opts) { 4 | var limiter = rateLimiter(opts); 5 | return function(req, res, next) { 6 | limiter(req, function(err, rate) { 7 | if (err) { 8 | next(); 9 | } else { 10 | if (rate.current > rate.limit) { 11 | res.writeHead(429); 12 | res.end(); 13 | } else { 14 | next(); 15 | } 16 | } 17 | }); 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /test/reset.js: -------------------------------------------------------------------------------- 1 | 2 | // Remove all rate-limiting keys from Redis 3 | // To clean up tests 4 | // Don't use in production because of bad O(n) performance 5 | // See: http://redis.io/commands/keys 6 | exports.allkeys = function(redisClient, done) { 7 | redisClient.keys('ratelimit*:*', function(err, keys) { 8 | if (err) return done(); 9 | if (keys.length === 0) return done(); 10 | redisClient.del.call(redisClient, keys, function(err) { 11 | done(); 12 | }); 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redis-rate-limiter", 3 | "version": "1.2.0", 4 | "description": "Rate-limit any operation, backed by Redis", 5 | "author": "Tabcorp Digital Team", 6 | "license": "MIT", 7 | "main": "lib/index.js", 8 | "repository": "Tabcorp/redis-rate-limiter", 9 | "scripts": { 10 | "lint": "require-lint", 11 | "test": "mocha" 12 | }, 13 | "dependencies": { 14 | "moment": "^2.19.3" 15 | }, 16 | "devDependencies": { 17 | "async": "^3.1.1", 18 | "express": "^4.16.2", 19 | "ioredis": "^4.2.0", 20 | "lodash": "^4.17.4", 21 | "mocha": "^8.1.3", 22 | "redis": "^3.0.2", 23 | "require-lint": "^2.0.2", 24 | "should": "^13.1.3", 25 | "supertest": "^4.0.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/rate-limiter.js: -------------------------------------------------------------------------------- 1 | var options = require('./options'); 2 | 3 | module.exports = function(opts) { 4 | opts = options.canonical(opts); 5 | return function(request, callback) { 6 | var key = opts.key(request); 7 | var tempKey = 'ratelimittemp:' + key; 8 | var realKey = 'ratelimit:' + key; 9 | opts.redis.multi() 10 | .setex(tempKey, opts.window(), 0) 11 | .renamenx(tempKey, realKey) 12 | .incr(realKey) 13 | .ttl(realKey) 14 | .exec(function(err, results) { 15 | if(err) { 16 | callback(err); 17 | } else { 18 | // if multi() used, ioredis returns an array of result set [err | null, value], we need to check the second parameter for such cases 19 | // see also: https://github.com/luin/ioredis/wiki/Migrating-from-node_redis 20 | const hasTimeToLive = Array.isArray(results[3]) ? results[3][1] : results[3]; 21 | if (hasTimeToLive === -1) { // automatically recover from possible race condition 22 | if (opts.deleteImmediatelyIfRaceCondition) { 23 | opts.redis.del(realKey); 24 | } else { 25 | opts.redis.expire(realKey, opts.window()); 26 | } 27 | 28 | if (typeof opts.onPossibleRaceCondition === 'function') { 29 | opts.onPossibleRaceCondition(realKey); 30 | } 31 | } 32 | const current = Array.isArray(results[2]) ? results[2][1] : results[2]; 33 | callback(null, { 34 | key: key, 35 | current: current, 36 | limit: opts.limit(), 37 | window: opts.window(), 38 | over: (current > opts.limit()) 39 | }); 40 | } 41 | }); 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /lib/options.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var moment = require('moment'); 3 | 4 | var getMatches = function(opts){ 5 | return getRate(opts).match(/^(\d+)\s*\/\s*([a-z]+)$/); 6 | }; 7 | 8 | var keyShorthands = { 9 | 'ip': function(req) { 10 | return req.connection.remoteAddress; 11 | } 12 | }; 13 | 14 | var getRate = function(opts){ 15 | if(typeof opts.rate === 'function') return opts.rate(); 16 | return opts.rate; 17 | }; 18 | 19 | var getLimit = function(opts){ 20 | if(getRate(opts)) return parseInt(getMatches(opts)[1], 10); 21 | if(typeof opts.limit === 'function') return opts.limit(); 22 | return opts.limit; 23 | }; 24 | 25 | var getWindow = function(opts){ 26 | if(getRate(opts)) return moment.duration(1, getMatches(opts)[2]) / 1000; 27 | if(typeof opts.window === 'function') return opts.window(); 28 | return opts.window; 29 | }; 30 | 31 | var getKey = function(opts){ 32 | if(typeof opts.key === 'function') return opts.key; 33 | return keyShorthands[opts.key]; 34 | }; 35 | 36 | var validate = function(opts){ 37 | assert.equal(typeof opts.redis, 'object', 'Invalid redis client'); 38 | assert.equal(typeof getKey(opts), 'function', 'Invalid key: ' + opts.key); 39 | if(opts.rate) assert.ok(getMatches(opts), 'Invalid rate: ' + getRate(opts)); 40 | assert.equal(typeof getLimit(opts), 'number', 'Invalid limit: ' + getLimit(opts)); 41 | assert.equal(typeof getWindow(opts), 'number', 'Invalid window: ' + getWindow(opts)); 42 | assert.notEqual(getLimit(opts), 0, 'Invalid rate limit: ' + getRate(opts)); 43 | assert.notEqual(getWindow(opts), 0, 'Invalid rate window: ' + getRate(opts)); 44 | }; 45 | 46 | canonical = function(opts) { 47 | validate(opts); 48 | return { 49 | redis: opts.redis, 50 | key: getKey(opts), 51 | rate: getRate.bind(null, opts), 52 | limit: getLimit.bind(null, opts), 53 | window: getWindow.bind(null, opts), 54 | deleteImmediatelyIfRaceCondition: opts.deleteImmediatelyIfRaceCondition, 55 | onPossibleRaceCondition: opts.onPossibleRaceCondition 56 | }; 57 | }; 58 | 59 | module.exports = { 60 | canonical: canonical 61 | }; 62 | -------------------------------------------------------------------------------- /test/rate-limiter.ioredis.spec.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const async = require('async'); 3 | const redis = require('ioredis'); 4 | const reset = require('./reset'); 5 | const rateLimiter = require('../lib/rate-limiter'); 6 | 7 | describe('IORedis Client test Rate-limiter', function () { 8 | 9 | this.slow(5000); 10 | this.timeout(5000); 11 | 12 | let client = null; 13 | 14 | before(function (done) { 15 | client = new redis(6379, 'localhost', {enable_offline_queue: false}); 16 | client.on('ready', done); 17 | }); 18 | 19 | beforeEach(function (done) { 20 | reset.allkeys(client, done); 21 | }); 22 | 23 | after(function () { 24 | client.quit(); 25 | }); 26 | 27 | it('calls back with the rate data', function (done) { 28 | var limiter = createLimiter('10/second'); 29 | var reqs = request(limiter, 5, {id: 'a'}); 30 | async.parallel(reqs, function (err, rates) { 31 | _.map(rates, 'current').should.eql([1, 2, 3, 4, 5]); 32 | _.each(rates, function (r) { 33 | r.key.should.eql('a'); 34 | r.limit.should.eql(10); 35 | r.window.should.eql(1); 36 | r.over.should.eql(false); 37 | }); 38 | done(); 39 | }); 40 | }); 41 | 42 | it('sets the over flag when above the limit', function (done) { 43 | var limiter = createLimiter('10/second'); 44 | var reqs = request(limiter, 15, {id: 'a'}); 45 | async.parallel(reqs, function (err, rates) { 46 | _.each(rates, function (r, index) { 47 | rates[index].over.should.eql(index >= 10); 48 | }); 49 | done(); 50 | }); 51 | }); 52 | 53 | it('uses one bucket per key', function (done) { 54 | var limiter = createLimiter('10/second'); 55 | var reqs = _.flatten([ 56 | request(limiter, 10, {id: 'a'}), 57 | request(limiter, 12, {id: 'b'}), 58 | request(limiter, 10, {id: 'c'}) 59 | ]); 60 | async.parallel(reqs, function (err, rates) { 61 | _.filter(rates, {over: true}).should.have.length(2); 62 | done(); 63 | }); 64 | }); 65 | 66 | it('can handle a lot of requests', function (done) { 67 | var limiter = createLimiter('1000/second'); 68 | var reqs = request(limiter, 1200, {id: 'a'}); 69 | async.parallel(reqs, function (err, rates) { 70 | rates[999].should.have.property('over', false); 71 | rates[1000].should.have.property('over', true); 72 | done(); 73 | }); 74 | }); 75 | 76 | it('resets after the window', function (done) { 77 | var limiter = createLimiter('10/second'); 78 | async.series([ 79 | requestParallel(limiter, 15, {id: 'a'}), 80 | wait(1100), 81 | requestParallel(limiter, 15, {id: 'a'}) 82 | ], function (err, data) { 83 | _.each(data[0], function (rate, index) { 84 | rate.should.have.property('over', index > 9); 85 | }); 86 | _.each(data[2], function (rate, index) { 87 | rate.should.have.property('over', index > 9); 88 | }); 89 | done(); 90 | }); 91 | }); 92 | 93 | function createLimiter(rate) { 94 | return rateLimiter({ 95 | redis: client, 96 | key: function (x) { 97 | return x.id 98 | }, 99 | rate: rate 100 | }); 101 | } 102 | 103 | function request(limiter, count, data) { 104 | return _.times(count, function () { 105 | return function (next) { 106 | limiter(data, next); 107 | }; 108 | }); 109 | } 110 | 111 | function requestParallel(limiter, count, data) { 112 | return function (next) { 113 | async.parallel(request(limiter, count, data), next); 114 | }; 115 | } 116 | 117 | function wait(millis) { 118 | return function (next) { 119 | setTimeout(next, 1100); 120 | }; 121 | } 122 | 123 | }); 124 | -------------------------------------------------------------------------------- /test/rate-limiter.spec.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var async = require('async'); 3 | var should = require('should'); 4 | var redis = require('redis'); 5 | var reset = require('./reset'); 6 | var rateLimiter = require('../lib/rate-limiter'); 7 | 8 | describe('Rate-limiter', function() { 9 | 10 | this.slow(5000); 11 | this.timeout(5000); 12 | 13 | var client = null; 14 | 15 | before(function(done) { 16 | client = redis.createClient(6379, 'localhost', {enable_offline_queue: false}); 17 | client.on('ready', done); 18 | }); 19 | 20 | beforeEach(function(done) { 21 | reset.allkeys(client, done); 22 | }); 23 | 24 | after(function() { 25 | client.quit(); 26 | }); 27 | 28 | it('calls back with the rate data', function(done) { 29 | var limiter = createLimiter('10/second'); 30 | var reqs = request(limiter, 5, {id: 'a'}); 31 | async.parallel(reqs, function(err, rates) { 32 | _.map(rates, 'current').should.eql([1, 2, 3, 4, 5]); 33 | _.each(rates, function(r) { 34 | r.key.should.eql('a'); 35 | r.limit.should.eql(10); 36 | r.window.should.eql(1); 37 | r.over.should.eql(false); 38 | }); 39 | done(); 40 | }); 41 | }); 42 | 43 | it('sets the over flag when above the limit', function(done) { 44 | var limiter = createLimiter('10/second'); 45 | var reqs = request(limiter, 15, {id: 'a'}); 46 | async.parallel(reqs, function(err, rates) { 47 | _.each(rates, function(r, index) { 48 | rates[index].over.should.eql(index >= 10); 49 | }); 50 | done(); 51 | }); 52 | }); 53 | 54 | it('uses one bucket per key', function(done) { 55 | var limiter = createLimiter('10/second'); 56 | var reqs = _.flatten([ 57 | request(limiter, 10, {id: 'a'}), 58 | request(limiter, 12, {id: 'b'}), 59 | request(limiter, 10, {id: 'c'}) 60 | ]); 61 | async.parallel(reqs, function(err, rates) { 62 | _.filter(rates, {over: true}).should.have.length(2); 63 | done(); 64 | }); 65 | }); 66 | 67 | it('can handle a lot of requests', function(done) { 68 | var limiter = createLimiter('1000/second'); 69 | var reqs = request(limiter, 1200, {id: 'a'}); 70 | async.parallel(reqs, function(err, rates) { 71 | rates[999].should.have.property('over', false); 72 | rates[1000].should.have.property('over', true); 73 | done(); 74 | }); 75 | }); 76 | 77 | it('resets after the window', function(done) { 78 | var limiter = createLimiter('10/second'); 79 | async.series([ 80 | requestParallel(limiter, 15, {id: 'a'}), 81 | wait(1100), 82 | requestParallel(limiter, 15, {id: 'a'}) 83 | ], function(err, data) { 84 | _.each(data[0], function(rate, index) { 85 | rate.should.have.property('over', index > 9); 86 | }); 87 | _.each(data[2], function(rate, index) { 88 | rate.should.have.property('over', index > 9); 89 | }); 90 | done(); 91 | }); 92 | }); 93 | 94 | function createLimiter(rate) { 95 | return rateLimiter({ 96 | redis: client, 97 | key: function(x) { return x.id }, 98 | rate: rate 99 | }); 100 | } 101 | 102 | function request(limiter, count, data) { 103 | return _.times(count, function() { 104 | return function(next) { 105 | limiter(data, next); 106 | }; 107 | }); 108 | } 109 | 110 | function requestParallel(limiter, count, data) { 111 | return function(next) { 112 | async.parallel(request(limiter, count, data), next); 113 | }; 114 | } 115 | 116 | function wait(millis) { 117 | return function(next) { 118 | setTimeout(next, 1100); 119 | }; 120 | } 121 | 122 | }); 123 | -------------------------------------------------------------------------------- /test/options.spec.js: -------------------------------------------------------------------------------- 1 | require('should'); 2 | var options = require('../lib/options'); 3 | 4 | describe('Options', function() { 5 | 6 | describe('key', function() { 7 | 8 | it('can specify a function', function() { 9 | var opts = options.canonical({ 10 | redis: {}, 11 | key: function(req) {return req.id;}, 12 | limit: 10, 13 | window: 60 14 | }); 15 | opts.key({ 16 | id: 5 17 | }).should.eql(5); 18 | }); 19 | 20 | it('can be the full client IP', function() { 21 | var opts = options.canonical({ 22 | redis: {}, 23 | key: 'ip', 24 | limit: 10, 25 | window: 60 26 | }); 27 | opts.key({ 28 | connection: { remoteAddress: '1.2.3.4' } 29 | }).should.eql('1.2.3.4'); 30 | }); 31 | 32 | it('fails for invalid keys', function() { 33 | (function() { 34 | var opts = options.canonical({ 35 | redis: {}, 36 | key: 'something', 37 | limit: 10, 38 | window: 60 39 | }); 40 | }).should.throw('Invalid key: something'); 41 | }); 42 | 43 | }); 44 | 45 | describe('limit and window', function() { 46 | 47 | it('should accept numeric values in seconds', function() { 48 | var opts = options.canonical({ 49 | redis: {}, 50 | key: 'ip', 51 | limit: 10, // 10 requests 52 | window: 60 // per 60 seconds 53 | }); 54 | opts.limit().should.eql(10); 55 | opts.window().should.eql(60); 56 | }); 57 | 58 | it('should allow functions to get the limit', function() { 59 | var opts = options.canonical({ 60 | redis: {}, 61 | key: 'ip', 62 | limit: function(){return 10;}, // 10 requests 63 | window: function(){return 60;} // per 60 seconds 64 | }); 65 | opts.limit().should.eql(10); 66 | opts.window().should.eql(60); 67 | }); 68 | 69 | }); 70 | 71 | describe('rate shorthand notation', function() { 72 | 73 | function assertRate(rate, limit, window) { 74 | var opts = options.canonical({ 75 | redis: {}, 76 | key: 'ip', 77 | rate: rate 78 | }); 79 | opts.limit().should.eql(limit, 'Wrong limit for rate ' + rate); 80 | opts.window().should.eql(window, 'Wrong window for rate ' + rate); 81 | } 82 | 83 | it('can use the full unit name (x/second)', function() { 84 | assertRate('10/second', 10, 1); 85 | assertRate('100/minute', 100, 60); 86 | assertRate('1000/hour', 1000, 3600); 87 | assertRate('5000/day', 5000, 86400); 88 | assertRate(function(){return '5000/day';}, 5000, 86400); 89 | }); 90 | 91 | it('can use the short unit name (x/s)', function() { 92 | assertRate('10/s', 10, 1); 93 | assertRate('100/m', 100, 60); 94 | assertRate('1000/h', 1000, 3600); 95 | assertRate('5000/d', 5000, 86400); 96 | assertRate(function(){return '5000/d';}, 5000, 86400); 97 | }); 98 | 99 | it('has to be a valid format', function() { 100 | (function() { 101 | var opts = options.canonical({ 102 | redis: {}, 103 | key: 'ip', 104 | rate: 'foobar' 105 | }); 106 | }).should.throw('Invalid rate: foobar'); 107 | }); 108 | 109 | it('has to be a valid limit', function() { 110 | (function() { 111 | var opts = options.canonical({ 112 | redis: {}, 113 | key: 'ip', 114 | rate: '0/hour' 115 | }); 116 | }).should.throw('Invalid rate limit: 0/hour'); 117 | }); 118 | 119 | it('has to be a valid unit', function() { 120 | (function() { 121 | var opts = options.canonical({ 122 | redis: {}, 123 | key: 'ip', 124 | rate: '50/century' 125 | }); 126 | }).should.throw('Invalid rate window: 50/century'); 127 | }); 128 | 129 | it('has to be a valid unit when passing a function to get the rate', function() { 130 | (function() { 131 | var opts = options.canonical({ 132 | redis: {}, 133 | key: 'ip', 134 | rate: function(){return '50/century';} 135 | }); 136 | }).should.throw('Invalid rate window: 50/century'); 137 | }); 138 | 139 | }); 140 | 141 | }); 142 | -------------------------------------------------------------------------------- /test/middleware.spec.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var async = require('async'); 3 | var should = require('should'); 4 | var redis = require('redis'); 5 | var express = require('express'); 6 | var supertest = require('supertest'); 7 | var reset = require('./reset'); 8 | var middleware = require('../lib/middleware'); 9 | 10 | describe('Middleware', function() { 11 | 12 | this.slow(5000); 13 | this.timeout(5000); 14 | 15 | var client = null; 16 | var limiter = null; 17 | 18 | before(function(done) { 19 | client = redis.createClient(6379, 'localhost', {enable_offline_queue: false}); 20 | client.on('ready', done); 21 | }); 22 | 23 | beforeEach(function(done) { 24 | reset.allkeys(client, done); 25 | }); 26 | 27 | after(function() { 28 | client.quit(); 29 | }); 30 | 31 | describe('IP throttling', function() { 32 | 33 | before(function() { 34 | limiter = middleware({ 35 | redis: client, 36 | key: 'ip', 37 | rate: '10/second' 38 | }); 39 | }); 40 | 41 | it('passes through under the limit', function(done) { 42 | var server = express(); 43 | server.use(limiter); 44 | server.use(okResponse); 45 | var reqs = requests(server, 9, '/test'); 46 | async.parallel(reqs, function(err, data) { 47 | withStatus(data, 200).should.have.length(9); 48 | done(); 49 | }); 50 | }); 51 | 52 | it('returns HTTP 429 over the limit', function(done) { 53 | var server = express(); 54 | server.use(limiter); 55 | server.use(okResponse); 56 | var reqs = requests(server, 12, '/test'); 57 | async.parallel(reqs, function(err, data) { 58 | withStatus(data, 200).should.have.length(10); 59 | withStatus(data, 429).should.have.length(2); 60 | done(); 61 | }); 62 | }); 63 | 64 | it('works across several rate-limit windows', function(done) { 65 | var server = express(); 66 | server.use(limiter); 67 | server.use(okResponse); 68 | async.series([ 69 | parallelRequests(server, 9, '/test'), 70 | wait(1100), 71 | parallelRequests(server, 12, '/test'), 72 | wait(1100), 73 | parallelRequests(server, 9, '/test') 74 | ], function(err, data) { 75 | withStatus(data[0], 200).should.have.length(9); 76 | withStatus(data[2], 200).should.have.length(10); 77 | withStatus(data[2], 429).should.have.length(2); 78 | withStatus(data[4], 200).should.have.length(9); 79 | done(); 80 | }); 81 | }); 82 | 83 | }); 84 | 85 | describe('Custom key throttling', function() { 86 | 87 | before(function() { 88 | limiter = middleware({ 89 | redis: client, 90 | key: function(req) { return req.query.user; }, 91 | rate: '10/second' 92 | }); 93 | }); 94 | 95 | it('uses a different bucket for each custom key (user)', function(done) { 96 | var server = express(); 97 | server.use(limiter); 98 | server.use(okResponse); 99 | var reqs = _.flatten([ 100 | requests(server, 5, '/test?user=a'), 101 | requests(server, 12, '/test?user=b'), 102 | requests(server, 10, '/test?user=c') 103 | ]); 104 | async.parallel(reqs, function(err, data) { 105 | withStatus(data, 200).should.have.length(25); 106 | withStatus(data, 429).should.have.length(2); 107 | withStatus(data, 429)[0].url.should.eql('/test?user=b'); 108 | withStatus(data, 429)[1].url.should.eql('/test?user=b'); 109 | done(); 110 | }); 111 | }); 112 | 113 | }); 114 | 115 | }); 116 | 117 | function requests(server, count, url) { 118 | return _.times(count, function() { 119 | return function(next) { 120 | supertest(server).get(url).end(next); 121 | }; 122 | }); 123 | } 124 | 125 | function parallelRequests(server, count, url) { 126 | return function(next) { 127 | async.parallel(requests(server, count, url), next); 128 | }; 129 | } 130 | 131 | function wait(millis) { 132 | return function(next) { 133 | setTimeout(next, 1100); 134 | }; 135 | } 136 | 137 | function okResponse(req, res, next) { 138 | res.writeHead(200); 139 | res.end('ok'); 140 | } 141 | 142 | function withStatus(data, code) { 143 | var pretty = data.map(function(d) { 144 | return { 145 | url: d.req.path, 146 | statusCode: d.res.statusCode, 147 | body: d.res.body 148 | } 149 | }); 150 | return _.filter(pretty, {statusCode: code}); 151 | } 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redis-rate-limiter 2 | 3 | [![NPM](http://img.shields.io/npm/v/redis-rate-limiter.svg?style=flat)](https://npmjs.org/package/redis-rate-limiter) 4 | [![License](http://img.shields.io/npm/l/redis-rate-limiter.svg?style=flat)](https://github.com/Tabcorp/redis-rate-limiter) 5 | 6 | [![Build Status](http://img.shields.io/travis/Tabcorp/redis-rate-limiter.svg?style=flat)](http://travis-ci.org/Tabcorp/redis-rate-limiter) 7 | [![Dependencies](http://img.shields.io/david/Tabcorp/redis-rate-limiter.svg?style=flat)](https://david-dm.org/Tabcorp/redis-rate-limiter) 8 | [![Dev dependencies](http://img.shields.io/david/dev/Tabcorp/redis-rate-limiter.svg?style=flat)](https://david-dm.org/Tabcorp/redis-rate-limiter) 9 | [![Known Vulnerabilities](https://snyk.io/package/npm/redis-rate-limiter/badge.svg)](https://snyk.io/package/npm/redis-rate-limiter) 10 | 11 | Rate-limit any operation, backed by Redis. 12 | 13 | - Inspired by [ratelimiter](https://www.npmjs.org/package/ratelimiter) 14 | - But uses a fixed-window algorithm 15 | - Great performance (>10000 checks/sec on local redis) 16 | - No race conditions 17 | 18 | Very easy to plug into `Express` or `Restify` to rate limit your `Node.js` API. 19 | 20 | ## Usage 21 | 22 | Step 1: create a Redis connection 23 | 24 | ```js 25 | var redis = require('redis'); 26 | var client = redis.createClient(6379, 'localhost', {enable_offline_queue: false}); 27 | ``` 28 | 29 | Step 2: create your rate limiter 30 | 31 | ```js 32 | var rateLimiter = require('redis-rate-limiter'); 33 | var limit = rateLimiter.create({ 34 | redis: client, 35 | key: function(x) { return x.id }, 36 | rate: '100/minute' 37 | }); 38 | ``` 39 | 40 | And go 41 | 42 | ```js 43 | limit(request, function(err, rate) { 44 | if (err) { 45 | console.warn('Rate limiting not available'); 46 | } else { 47 | console.log('Rate window: ' + rate.window); // 60 48 | console.log('Rate limit: ' + rate.limit); // 100 49 | console.log('Rate current: ' + rate.current); // 74 50 | if (rate.over) { 51 | console.error('Over the limit!'); 52 | } 53 | } 54 | }); 55 | ``` 56 | 57 | ## Options 58 | 59 | ### `redis` 60 | 61 | A pre-created Redis client. 62 | Make sure offline queueing is disabled. 63 | 64 | ```js 65 | var client = redis.createClient(6379, 'localhost', { 66 | enable_offline_queue: false 67 | }); 68 | ``` 69 | 70 | ### `key` 71 | 72 | The key is how requests are grouped for rate-limiting. 73 | Typically, this would be a user ID, a type of operation. 74 | 75 | You can also specify any custom function: 76 | 77 | ```js 78 | // rate-limit each user separately 79 | key: function(x) { return x.user.id; } 80 | 81 | // rate limit per user and operation type 82 | key: function(x) { return x.user.id + ':' + x.operation; } 83 | 84 | // rate limit everyone in the same bucket 85 | key: function(x) { return 'single-bucket'; } 86 | ``` 87 | 88 | You can also use the built-in `ip` shorthand, which gets the remote address from an HTTP request. 89 | 90 | ```js 91 | key: 'ip' 92 | ``` 93 | 94 | ### `window` 95 | 96 | This is the duration over which rate-limiting is applied, in seconds. 97 | 98 | ```js 99 | // rate limit per minute 100 | window: 60 101 | 102 | // rate limit per hour 103 | window: 3600 104 | ``` 105 | 106 | Note that this is **not a rolling window**. 107 | If you specify `10 requests / minute`, a user would be able 108 | to execute 10 requests at `00:59` and another 10 at `01:01`. 109 | Then they won't be able to make another request until `02:00`. 110 | 111 | 112 | ### `limit` 113 | 114 | This is the total number of requests a unique `key` can make during the `window`. 115 | 116 | ```js 117 | limit: 100 118 | ``` 119 | 120 | ### `rate` 121 | 122 | Rate is a shorthand notation to combine `limit` and `window`. 123 | 124 | ```js 125 | rate: '10/second' 126 | rate: '100/minute' 127 | rate: '1000/hour' 128 | ``` 129 | 130 | Or the even shorter 131 | 132 | ```js 133 | rate: '10/s' 134 | rate: '100/m' 135 | rate: '100/h' 136 | ``` 137 | 138 | *Note:* the rate is parsed ahead of time, so this notation doesn't affect performance. 139 | 140 | ## HTTP middleware 141 | 142 | This package contains a pre-built middleware, 143 | which takes the same options 144 | 145 | 146 | ```js 147 | var rateLimiter = require('redis-rate-limiter'); 148 | 149 | var middleware = rateLimiter.middleware({ 150 | redis: client, 151 | key: 'ip', 152 | rate: '100/minute' 153 | }); 154 | 155 | server.use(middleware); 156 | ``` 157 | 158 | It rejects any rate-limited requests with a status code of `HTTP 429`, 159 | and an empty body. 160 | 161 | *Note:* if you want to rate limit several routes individually, don't forget to use the route name as part of the `key`, for example using Restify: 162 | 163 | ```js 164 | function ipAndRoute(req) { 165 | return req.connection.remoteAddress + ':' + req.route.name; 166 | } 167 | 168 | server.get( 169 | {name: 'routeA', path: '/a'}, 170 | rateLimiter.middleware({redis: client, key: ipAndRoute, rate: '10/minute'}), 171 | controllerA 172 | ); 173 | 174 | server.get( 175 | {name: 'routeB', path: '/b'}, 176 | rateLimiter.middleware({redis: client, key: ipAndRoute, rate: '20/minute'}), 177 | controllerB 178 | ); 179 | ``` 180 | --------------------------------------------------------------------------------