├── .gitignore
├── .travis.yml
├── LICENSE.md
├── README.md
├── index.js
├── lib
├── bucket.js
├── table.js
└── xor.js
├── makefile
├── package.json
└── test
├── fixtures
├── client.js
└── jshint.json
├── governance
├── lint_index.js
└── lint_lib.js
├── integration
├── ip_error.js
├── ip_ok.js
├── username_error.js
├── username_ok.js
├── xff_error.js
└── xff_ok.js
└── unit
├── bucket.js
├── table.js
└── xor.js
/.gitignore:
--------------------------------------------------------------------------------
1 | /* MacOS */
2 | .DS_Store
3 |
4 | /* NPM */
5 | /node_modules
6 | npm*
7 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "0.8"
4 | - "0.10"
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2012 Andrew Sliwinski
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Micron-throttle
2 | #### Token bucket based HTTP request throttle for Node.js
3 |
4 | [](https://travis-ci.org/thisandagain/micron-throttle)
5 |
6 | ### Installation
7 | ```bash
8 | npm install micron-throttle
9 | ```
10 |
11 | ### Basic Use
12 | The throttle module can be used in just about any application that supports the `function (req, res, next)` middleware convention (such as express, connect, union or restify). For example in restify:
13 | ```js
14 | var http = require('restify'),
15 | throttle = require('micron-throttle');
16 |
17 | // Create the server and pass-in the throttle middleware
18 | var server = restify.createServer();
19 | server.use(throttle({
20 | burst: 100,
21 | rate: 50,
22 | ip: true,
23 | overrides: {
24 | '192.168.1.1': {
25 | rate: 0, // unlimited
26 | burst: 0
27 | }
28 | }
29 | }));
30 |
31 | // Define a route
32 | server.get('/hello/:name', function (req, res, next) {
33 | res.send('hello ' + req.params.name);
34 | });
35 |
36 | // Listen
37 | server.listen(3333);
38 | ```
39 |
40 | ### Options
41 |
42 |
43 |
44 | Name |
45 | Default |
46 | Type |
47 | Description |
48 |
49 |
50 |
51 | rate | 10 | Number | Steady state number of requests/second to allow |
52 | burst | 25 | Number | If available, the amount of requests to burst to |
53 | ip | true | Boolean | Do throttling on a /32 (source IP) |
54 | xff | false | Boolean | Do throttling on a /32 (X-Forwarded-For) |
55 | username | false | Boolean | Do throttling on req.username |
56 | overrides | null | Object | Per "key" overrides |
57 | tokensTable | n/a | Object | Storage engine; must support set/get |
58 | maxKeys | 10000 | Number | If using the built-in storage table, the maximum distinct throttling keys to allow at a time |
59 |
60 |
61 |
62 | ### Testing
63 | ```bash
64 | npm test
65 | ```
66 |
67 | ### Credits
68 | This module is adapted from [restify's](https://github.com/mcavage/node-restify) throttle plug-in – originally developed by [Mark Cavage](https://github.com/mcavage).
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Token bucket based HTTP request throttle for Node.js.
3 | *
4 | * @package restify-throttle
5 | * @author Mark Cavage
6 | * Andrew Sliwinski
7 | */
8 |
9 | /**
10 | * Dependencies
11 | */
12 | var _ = require('lodash'),
13 | assert = require('assert-plus'),
14 | sprintf = require('util').format;
15 |
16 | var xor = require('./lib/xor.js'),
17 | TokenBucket = require('./lib/bucket.js'),
18 | TokenTable = require('./lib/table.js');
19 |
20 | var MESSAGE = 'You have exceeded your request rate of %s r/s.';
21 |
22 | function Throttle () {
23 | var self = this;
24 |
25 | self.burst = 25;
26 | self.rate = 10;
27 | self.ip = false;
28 | self.xff = false;
29 | self.username = false;
30 | self.overrides = null;
31 | self.table = new TokenTable({
32 | size: 10000
33 | });
34 |
35 | var rateLimit = function (req, res, next) {
36 | var attr;
37 | if (self.ip) {
38 | attr = req.connection.remoteAddress;
39 | } else if (self.xff) {
40 | attr = req.headers['x-forwarded-for'];
41 | } else if (self.username) {
42 | attr = req.username;
43 | } else {
44 | return next();
45 | }
46 |
47 | // Before bothering with overrides, see if this request
48 | // even matches
49 | if (!attr) return next();
50 |
51 | // Check the overrides
52 | var burst = self.burst;
53 | var rate = self.rate;
54 | if (self.overrides &&
55 | self.overrides[attr] &&
56 | self.overrides[attr].burst !== undefined &&
57 | self.overrides[attr].rate !== undefined) {
58 | burst = self.overrides[attr].burst;
59 | rate = self.overrides[attr].rate;
60 | }
61 |
62 | // Check if bucket exists, else create new
63 | var bucket = self.table.get(attr);
64 |
65 | if (!bucket) {
66 | bucket = new TokenBucket({
67 | capacity: burst,
68 | fillRate: rate
69 | });
70 | self.table.set(attr, bucket);
71 | }
72 |
73 | // Throttle request
74 | if (!bucket.consume(1)) {
75 | // Until https://github.com/joyent/node/pull/2371 is in
76 | var msg = sprintf(MESSAGE, rate);
77 | res.writeHead(429, 'application/json');
78 | res.end('{"error":"' + msg + '"}');
79 | return;
80 | }
81 |
82 | return next();
83 | };
84 |
85 | /**
86 | * Creates an API rate limiter that can be plugged into the standard
87 | * restify request handling pipeline.
88 | *
89 | * This throttle gives you three options on which to throttle:
90 | * username, IP address and 'X-Forwarded-For'. IP/XFF is a /32 match,
91 | * so keep that in mind if using it. Username takes the user specified
92 | * on req.username (which gets automagically set for supported Authorization
93 | * types; otherwise set it yourself with a filter that runs before this).
94 | *
95 | * In both cases, you can set a `burst` and a `rate` (in requests/seconds),
96 | * as an integer/float. Those really translate to the `TokenBucket`
97 | * algorithm, so read up on that (or see the comments above...).
98 | *
99 | * In either case, the top level options burst/rate set a blanket throttling
100 | * rate, and then you can pass in an `overrides` object with rates for
101 | * specific users/IPs. You should use overrides sparingly, as we make a new
102 | * TokenBucket to track each.
103 | *
104 | * On the `options` object ip and username are treated as an XOR.
105 | *
106 | * An example options object with overrides:
107 | *
108 | * {
109 | * burst: 10, // Max 10 concurrent requests (if tokens)
110 | * rate: 0.5, // Steady state: 1 request / 2 seconds
111 | * ip: true, // throttle per IP
112 | * overrides: {
113 | * '192.168.1.1': {
114 | * burst: 0,
115 | * rate: 0 // unlimited
116 | * }
117 | * }
118 | *
119 | *
120 | * @param {Object} options required options with:
121 | * - {Number} burst (required).
122 | * - {Number} rate (required).
123 | * - {Boolean} ip (optional).
124 | * - {Boolean} username (optional).
125 | * - {Boolean} xff (optional).
126 | * - {Object} overrides (optional).
127 | * - {Object} tokensTable: a storage engine this plugin
128 | * will use to store throttling keys -> bucket
129 | * mappings. If you don't specify this, the
130 | * default is to use an in-memory O(1) LRU,
131 | * with 10k distinct keys. Any implementation
132 | * just needs to support set/get.
133 | * - {Number} maxKeys: If using the default
134 | * implementation, you can specify how large
135 | * you want the table to be. Default is 10000.
136 | * @return {Function} of type f(req, res, next) to be plugged into a route.
137 | * @throws {TypeError} on bad input.
138 | */
139 | return function (options) {
140 | // Apply options to instance
141 | _.extend(self, options);
142 |
143 | // Validate options
144 | var keySelection = xor(self.ip, self.xff, self.username);
145 | assert.ok(keySelection, '(ip ^ username ^ xff)');
146 | assert.number(self.burst, 'options.burst');
147 | assert.number(self.rate, 'options.rate');
148 |
149 | // Return middleware
150 | return rateLimit;
151 | };
152 | }
153 |
154 | module.exports = new Throttle();
--------------------------------------------------------------------------------
/lib/bucket.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Token bucket implementation.
3 | *
4 | * @package restify-throttle
5 | * @author Mark Cavage
6 | * Andrew Sliwinski
7 | */
8 |
9 | /**
10 | * Dependencies
11 | */
12 | var assert = require('assert-plus');
13 |
14 | /**
15 | * An implementation of the Token Bucket algorithm.
16 | *
17 | * Basically, in network throttling, there are two "mainstream"
18 | * algorithms for throttling requests, Token Bucket and Leaky Bucket.
19 | * For restify, I went with Token Bucket. For a good description of the
20 | * algorithm, see: http://en.wikipedia.org/wiki/Token_bucket
21 | *
22 | * In the options object, you pass in the total tokens and the fill rate.
23 | * Practically speaking, this means "allow `fill rate` requests/second,
24 | * with bursts up to `total tokens`". Note that the bucket is initialized
25 | * to full.
26 | *
27 | * Also, in googling, I came across a concise python implementation, so this
28 | * is just a port of that. Thanks http://code.activestate.com/recipes/511490 !
29 | *
30 | * @param {Object} options contains the parameters:
31 | * - {Number} capacity the maximum burst.
32 | * - {Number} fillRate the rate to refill tokens.
33 | */
34 | function Bucket (options) {
35 | assert.object(options, 'options');
36 | assert.number(options.capacity, 'options.capacity');
37 | assert.number(options.fillRate, 'options.fillRate');
38 |
39 | this.tokens = this.capacity = options.capacity;
40 | this.fillRate = options.fillRate;
41 | this.time = Date.now();
42 | }
43 |
44 | /**
45 | * Consume N tokens from the bucket.
46 | *
47 | * If there is not capacity, the tokens are not pulled from the bucket.
48 | *
49 | * @param {Number} tokens the number of tokens to pull out.
50 | * @return {Boolean} true if capacity, false otherwise.
51 | */
52 | Bucket.prototype.consume = function (tokens) {
53 | if (tokens <= this.fill()) {
54 | this.tokens -= tokens;
55 | return true;
56 | }
57 |
58 | return false;
59 | };
60 |
61 | /**
62 | * Fills the bucket with more tokens.
63 | *
64 | * Rather than do some whacky setTimeout() deal, we just approximate refilling
65 | * the bucket by tracking elapsed time from the last time we touched the bucket.
66 | *
67 | * Simply, we set the bucket size to
68 | * min(totalTokens, current + (fillRate * elapsed time)).
69 | *
70 | * @return {Number} the current number of tokens in the bucket.
71 | */
72 | Bucket.prototype.fill = function () {
73 | var now = Date.now();
74 | if (now < this.time) {
75 | // Reset - account for clock drift (like DST)
76 | this.time = now - 1000;
77 | }
78 |
79 | if (this.tokens < this.capacity) {
80 | var delta = this.fillRate * ((now - this.time) / 1000);
81 | this.tokens = Math.min(this.capacity, this.tokens + delta);
82 | }
83 | this.time = now;
84 |
85 | return this.tokens;
86 | };
87 |
88 | /**
89 | * Export
90 | */
91 | module.exports = Bucket;
--------------------------------------------------------------------------------
/lib/table.js:
--------------------------------------------------------------------------------
1 | /**
2 | * LRU table prototype.
3 | *
4 | * @package restify-throttle
5 | * @author Andrew Sliwinski
6 | */
7 |
8 | /**
9 | * Dependencies
10 | */
11 | var assert = require('assert-plus'),
12 | LRU = require('lru-cache');
13 |
14 | /**
15 | * Constructor
16 | */
17 | function Table (options) {
18 | assert.object(options, 'options');
19 | this.table = new LRU(options.size || 10000);
20 | }
21 |
22 | Table.prototype.get = function (key) {
23 | return this.table.get(key);
24 | };
25 |
26 | Table.prototype.set = function (key, value) {
27 | return this.table.set(key, value);
28 | };
29 |
30 | /**
31 | * Export
32 | */
33 | module.exports = Table;
--------------------------------------------------------------------------------
/lib/xor.js:
--------------------------------------------------------------------------------
1 | /**
2 | * XOR helper.
3 | *
4 | * @package restify-throttle
5 | * @author Mark Cavage
6 | * Andrew Sliwinski
7 | */
8 |
9 | /**
10 | * Export
11 | */
12 | module.exports = function () {
13 | var x = false;
14 | for (var i = 0; i < arguments.length; i++) {
15 | if (arguments[i] && !x) {
16 | x = true;
17 | } else if (arguments[i] && x) {
18 | return false;
19 | }
20 | }
21 | return x;
22 | };
--------------------------------------------------------------------------------
/makefile:
--------------------------------------------------------------------------------
1 | test:
2 | tap test/governance/*.js
3 | tap test/unit/*.js
4 | tap test/integration/*.js
5 |
6 | .PHONY: test
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": "Andrew Sliwinski (http://andrewsliwinski.com)",
3 | "name": "micron-throttle",
4 | "description": "Token bucket based HTTP request throttle for Node.js",
5 | "version": "0.1.0",
6 | "repository": {
7 | "url": "https://github.com/thisandagain/micron-throttle"
8 | },
9 | "main": "./lib/index.js",
10 | "scripts": {
11 | "test": "make test"
12 | },
13 | "dependencies": {
14 | "assert-plus": "~0.1.4",
15 | "lodash": "~2.0.0",
16 | "lru-cache": "~2.3.1"
17 | },
18 | "devDependencies": {
19 | "async": "~0.2.9",
20 | "hint-hint": "~0.3.0",
21 | "tap": "~0.3.1"
22 | },
23 | "optionalDependencies": {},
24 | "engines": {
25 | "node": ">=0.8"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/test/fixtures/client.js:
--------------------------------------------------------------------------------
1 | var async = require('async'),
2 | http = require('http');
3 |
4 | module.exports = function (rate, callback) {
5 | var queue = async.queue(function (task, callback) {
6 | http.get('http://localhost:8881/' + task, function (res) {
7 | var response = (res.statusCode === 200) ? null : res.statusCode;
8 | callback(response);
9 | }).on('error', function (err) {
10 | callback(err);
11 | });
12 | }, 2);
13 |
14 | for (var i = 0; i < rate; i++) {
15 | queue.push(i, function (err) {
16 | if (err) return callback(err);
17 | });
18 | }
19 |
20 | queue.drain = function (err) {
21 | if (err) return callback(err);
22 | callback(null);
23 | };
24 | };
--------------------------------------------------------------------------------
/test/fixtures/jshint.json:
--------------------------------------------------------------------------------
1 | {
2 | "bitwise": true,
3 | "devel": true,
4 | "eqeqeq": true,
5 | "immed": true,
6 | "latedef": true,
7 | "maxdepth": 2,
8 | "maxparams": 4,
9 | "newcap": true,
10 | "noarg": true,
11 | "node": true,
12 | "proto": true,
13 | "quotmark": "single",
14 | "undef": true,
15 | "unused": true,
16 | "maxlen": 80
17 | }
--------------------------------------------------------------------------------
/test/governance/lint_index.js:
--------------------------------------------------------------------------------
1 | require('hint-hint')(
2 | __dirname + '/../../*.js',
3 | require('../fixtures/jshint.json')
4 | );
--------------------------------------------------------------------------------
/test/governance/lint_lib.js:
--------------------------------------------------------------------------------
1 | require('hint-hint')(
2 | __dirname + '/../../!(node_modules)**/*.js',
3 | require('../fixtures/jshint.json')
4 | );
--------------------------------------------------------------------------------
/test/integration/ip_error.js:
--------------------------------------------------------------------------------
1 | var http = require('http'),
2 | test = require('tap').test,
3 | client = require('../fixtures/client.js'),
4 | throttle = require('../../index.js');
5 |
6 | var server = http.createServer(function (req, res) {
7 | throttle({
8 | burst: 25,
9 | rate: 25,
10 | ip: true
11 | })(req, res, function (err) {
12 | var code = (!err) ? 200 : 429;
13 | res.writeHead(code, {'Content-Type': 'text/plain'});
14 | res.end('\n');
15 | });
16 | }).listen(8881);
17 |
18 | client(100, function (err) {
19 | test('client', function (t) {
20 | t.equal(err, 429, 'error object of expected value');
21 | t.end();
22 | });
23 | });
24 |
25 | setTimeout(process.exit, 1200);
--------------------------------------------------------------------------------
/test/integration/ip_ok.js:
--------------------------------------------------------------------------------
1 | var http = require('http'),
2 | test = require('tap').test,
3 | client = require('../fixtures/client.js'),
4 | throttle = require('../../index.js');
5 |
6 | var server = http.createServer(function (req, res) {
7 | throttle({
8 | burst: 100,
9 | rate: 100,
10 | ip: true
11 | })(req, res, function (err) {
12 | var code = (!err) ? 200 : 429;
13 | res.writeHead(code, {'Content-Type': 'text/plain'});
14 | res.end('\n');
15 | });
16 | }).listen(8881);
17 |
18 | client(100, function (err) {
19 | test('client', function (t) {
20 | t.equal(err, null, 'error object of expected value');
21 | t.end();
22 | });
23 | });
24 |
25 | setTimeout(process.exit, 1200);
--------------------------------------------------------------------------------
/test/integration/username_error.js:
--------------------------------------------------------------------------------
1 | var http = require('http'),
2 | test = require('tap').test,
3 | client = require('../fixtures/client.js'),
4 | throttle = require('../../index.js');
5 |
6 | var server = http.createServer(function (req, res) {
7 | req.username = 'testuser';
8 |
9 | throttle({
10 | burst: 50,
11 | rate: 50,
12 | username: true
13 | })(req, res, function (err) {
14 | var code = (!err) ? 200 : 429;
15 | res.writeHead(code, {'Content-Type': 'text/plain'});
16 | res.end('\n');
17 | });
18 | }).listen(8881);
19 |
20 | client(100, function (err) {
21 | test('client', function (t) {
22 | t.equal(err, 429, 'error object of expected value');
23 | t.end();
24 | });
25 | });
26 |
27 | setTimeout(process.exit, 1200);
--------------------------------------------------------------------------------
/test/integration/username_ok.js:
--------------------------------------------------------------------------------
1 | var http = require('http'),
2 | test = require('tap').test,
3 | client = require('../fixtures/client.js'),
4 | throttle = require('../../index.js');
5 |
6 | var server = http.createServer(function (req, res) {
7 | req.username = 'testuser';
8 |
9 | throttle({
10 | burst: 100,
11 | rate: 100,
12 | username: true
13 | })(req, res, function (err) {
14 | var code = (!err) ? 200 : 429;
15 | res.writeHead(code, {'Content-Type': 'text/plain'});
16 | res.end('\n');
17 | });
18 | }).listen(8881);
19 |
20 | client(100, function (err) {
21 | test('client', function (t) {
22 | t.equal(err, null, 'error object of expected value');
23 | t.end();
24 | });
25 | });
26 |
27 | setTimeout(process.exit, 1200);
--------------------------------------------------------------------------------
/test/integration/xff_error.js:
--------------------------------------------------------------------------------
1 | var http = require('http'),
2 | test = require('tap').test,
3 | client = require('../fixtures/client.js'),
4 | throttle = require('../../index.js');
5 |
6 | var server = http.createServer(function (req, res) {
7 | req.headers['x-forwarded-for'] = '127.0.0.1,10.10.10.10';
8 |
9 | throttle({
10 | burst: 50,
11 | rate: 50,
12 | xff: true
13 | })(req, res, function (err) {
14 | var code = (!err) ? 200 : 429;
15 | res.writeHead(code, {'Content-Type': 'text/plain'});
16 | res.end('\n');
17 | });
18 | }).listen(8881);
19 |
20 | client(100, function (err) {
21 | test('client', function (t) {
22 | t.equal(err, 429, 'error object of expected value');
23 | t.end();
24 | });
25 | });
26 |
27 | setTimeout(process.exit, 1200);
--------------------------------------------------------------------------------
/test/integration/xff_ok.js:
--------------------------------------------------------------------------------
1 | var http = require('http'),
2 | test = require('tap').test,
3 | client = require('../fixtures/client.js'),
4 | throttle = require('../../index.js');
5 |
6 | var server = http.createServer(function (req, res) {
7 | req.headers['x-forwarded-for'] = '127.0.0.1,10.10.10.10';
8 |
9 | throttle({
10 | burst: 100,
11 | rate: 100,
12 | xff: true
13 | })(req, res, function (err) {
14 | var code = (!err) ? 200 : 429;
15 | res.writeHead(code, {'Content-Type': 'text/plain'});
16 | res.end('\n');
17 | });
18 | }).listen(8881);
19 |
20 | client(100, function (err) {
21 | test('client', function (t) {
22 | t.equal(err, null, 'error object of expected value');
23 | t.end();
24 | });
25 | });
26 |
27 | setTimeout(process.exit, 1200);
--------------------------------------------------------------------------------
/test/unit/bucket.js:
--------------------------------------------------------------------------------
1 | var test = require('tap').test,
2 | Bucket = require('../../lib/bucket.js');
3 |
4 | test('table', function (t) {
5 | var bucket = new Bucket({
6 | capacity: 10,
7 | fillRate: 10
8 | });
9 |
10 | t.ok(bucket.consume(2), 'is true');
11 | t.ok(bucket.consume(8), 'is true');
12 | t.notOk(bucket.consume(1), 'is false');
13 |
14 | setTimeout(function () {
15 | t.ok(bucket.consume(1), 'is true');
16 | t.ok(bucket.consume(1), 'is true');
17 | t.ok(bucket.consume(1), 'is true');
18 | t.ok(bucket.consume(1), 'is true');
19 | t.notOk(bucket.consume(10), 'is false');
20 | t.end();
21 | }, 1000);
22 | });
--------------------------------------------------------------------------------
/test/unit/table.js:
--------------------------------------------------------------------------------
1 | var test = require('tap').test,
2 | Table = require('../../lib/table.js');
3 |
4 | test('table', function (t) {
5 | var table = new Table({size:100});
6 |
7 | var set = table.set('127.0.0.1', {hello: 'world'});
8 | var get = table.get('127.0.0.1');
9 |
10 | t.equal(set, true, 'returns expected value');
11 | t.deepEqual(get, {hello: 'world'}, 'returns expected value');
12 | t.end();
13 | });
--------------------------------------------------------------------------------
/test/unit/xor.js:
--------------------------------------------------------------------------------
1 | var test = require('tap').test,
2 | xor = require('../../lib/xor.js');
3 |
4 | var suite = [
5 | xor(true, false, false),
6 | xor(false, false, false),
7 | ];
8 |
9 | test('xor', function (t) {
10 | t.ok(suite[0], 'returns true');
11 | t.notOk(suite[1], 'returns false');
12 | t.end();
13 | });
--------------------------------------------------------------------------------