express-throttle
Advanced tools
Comparing version 0.1.0 to 1.0.0
"use strict"; | ||
// The throttling is done using the "token bucket" method | ||
// https://en.wikipedia.org/wiki/Token_bucket | ||
// Default token storage (memory-bounded LRU cache) | ||
var MemoryStore = require("./memory-store"); | ||
function Throttle() { | ||
var options = parse_args(arguments); | ||
function Throttle(options) { | ||
var opts = parse_options(options); | ||
var rate = opts.rate.amount / opts.rate.interval; | ||
var burst = opts.burst || opts.rate.amount; | ||
var store = opts.store || new MemoryStore(10000); | ||
// Memory-bounded LRU cache | ||
var store = options.store || new MemoryStore(10000); | ||
// key function, used to identify the client we are going to throttle | ||
var key = options.key || function(req) { | ||
var key_func = opts.key || function(req) { | ||
return req.headers["x-forwarded-for"] || req.connection.remoteAddress; | ||
}; | ||
var on_throttled = options.on_throttled || function(req, res) { | ||
// cost function, calculates the number of tokens to be subtracted per request | ||
var cost_func; | ||
if (typeof(opts.cost) == "number") { | ||
cost_func = function() { return opts.cost; }; | ||
} else if (typeof(opts.cost) == "function") { | ||
cost_func = opts.cost; | ||
} else { | ||
cost_func = function() { return 1; }; | ||
} | ||
var on_throttled = opts.on_throttled || function(req, res) { | ||
res.status(429).end(); | ||
}; | ||
return function(req, res, next) { | ||
var k = key(req); | ||
var key = key_func(req); | ||
var cost = cost_func(req); | ||
store.get(k, function(err, entry) { | ||
store.get(key, function(err, entry) { | ||
if (err) { | ||
@@ -30,5 +43,5 @@ return next(err); | ||
entry = entry || { "tokens": options.burst }; | ||
var is_conforming = consume_tokens(entry, options.burst, options.rate); | ||
store.set(k, entry, function(err) { | ||
entry = entry || { "tokens": burst }; | ||
var passthrough = consume_tokens(entry, rate, burst, cost); | ||
store.set(key, entry, function(err) { | ||
if (err) { | ||
@@ -38,3 +51,3 @@ return next(err); | ||
if (is_conforming) { | ||
if (passthrough) { | ||
next(); | ||
@@ -49,24 +62,44 @@ } else { | ||
function parse_args(args) { | ||
args = [].slice.call(args); // Convert to array | ||
var options; | ||
function shallow_clone(obj) { | ||
var clone = {}; | ||
if (args.length === 1) { | ||
options = args[0]; | ||
} else if (args.length === 2) { | ||
options = { "burst": args[0], "rate": args[1] }; | ||
} else { | ||
throw new Error("invalid number of arguments."); | ||
for (var key in obj) { | ||
clone[key] = obj[key]; | ||
} | ||
if (typeof(options) != "object") | ||
return clone; | ||
} | ||
function parse_options(options) { | ||
if (typeof(options) == "string") { | ||
options = { "rate": options }; | ||
} | ||
if (typeof(options) != "object") { | ||
throw new Error("options needs to be an object."); | ||
} else { | ||
options = shallow_clone(options); | ||
} | ||
if (typeof(options.rate) != "string") { | ||
throw new Error("'rate' needs to be a string (e.g 2.5/s, 5/min, 10/day)."); | ||
} | ||
if (typeof(options.burst) != "number") | ||
options.rate = parse_rate(options.rate); | ||
if (options.burst && typeof(options.burst) != "number") { | ||
throw new Error("'burst' needs to be a number."); | ||
} | ||
if (options.key && typeof(options.key) != "function") { | ||
throw new Error("'key' needs to be a function."); | ||
} | ||
if (typeof(options.rate) != "string") | ||
throw new Error("'rate' needs to be a string of the form <integer>/<time-unit> (e.g 5/s, 10/min, 500/day)"); | ||
if (options.cost && !(typeof(options.cost) == "number" || typeof(options.cost) == "function")) { | ||
throw new Error("'cost' needs to be a number or function."); | ||
} | ||
options.rate = parse_rate(options.rate); | ||
if (options.on_throttled && typeof(options.on_throttled) != "function") { | ||
throw new Error("'on_throttled' needs to be a function."); | ||
} | ||
@@ -76,3 +109,3 @@ return options; | ||
var RATE_PATTERN = /^(\d+)\/(s|sec|second|m|min|minute|h|hour|d|day)$/; | ||
var RATE_PATTERN = /^(\d+(\.\d+)?)\/(s|sec|second|m|min|minute|h|hour|d|day)$/; | ||
@@ -82,9 +115,10 @@ function parse_rate(rate) { | ||
if (!parsed_rate) | ||
throw new Error("invalid rate, needs to be of the form <integer>/<time-unit> (e.g 5/s, 10/min, 500/day)"); | ||
if (!parsed_rate) { | ||
throw new Error("invalid rate (e.g 2.5/s, 5/min, 10/day)."); | ||
} | ||
var amount = parseInt(parsed_rate[1], 10); | ||
var time_unit_in_ms = time_unit_to_ms(parsed_rate[2]); | ||
var amount = parseFloat(parsed_rate[1]); | ||
var interval = time_unit_to_ms(parsed_rate[3]); | ||
return amount / time_unit_in_ms; | ||
return { "amount": amount, "interval": interval }; | ||
} | ||
@@ -107,3 +141,3 @@ | ||
function consume_tokens(entry, burst, rate) { | ||
function consume_tokens(entry, rate, burst, cost) { | ||
var now = Date.now(); | ||
@@ -120,3 +154,3 @@ | ||
if (entry.tokens >= 1) { | ||
entry.tokens -= 1; | ||
entry.tokens -= cost; | ||
return true; | ||
@@ -123,0 +157,0 @@ } else { |
{ | ||
"name": "express-throttle", | ||
"version": "0.1.0", | ||
"version": "1.0.0", | ||
"description": "Request throttling middleware for Express", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
173
README.md
# express-throttle | ||
Request throttling middleware for Express framework | ||
## Installation | ||
```bash | ||
$ npm install express-throttle | ||
``` | ||
## Implementation | ||
The throttling is done using the canonical [token bucket](https://en.wikipedia.org/wiki/Token_bucket) algorithm, where tokens are "refilled" in a sliding window manner (as opposed to a fixed time interval). This means that if we set maximum rate of 10 requests / minute, a client will not be able to send 10 requests 0:59, and 10 more 1:01. However, if the client sends 10 requests at 0:30, he will be able to send a new request at 0:36 (as tokens are regenerated continuously 1 every 6 seconds). | ||
## Examples | ||
```js | ||
var express = require("express"); | ||
var throttle = require("express-throttle"); | ||
var app = express(); | ||
// Throttle to 5 reqs/s | ||
app.post("/search", throttle("5/s"), function(req, res, next) { | ||
// ... | ||
}); | ||
``` | ||
Combine it with a burst capacity of 10, meaning that the client can make 10 requests at any rate. The capacity is "refilled" with the specified rate (in this case 5/s). | ||
```js | ||
app.post("/search", throttle({ "rate": "5/s", "burst": 10 }), function(req, res, next) { | ||
// ... | ||
}); | ||
// Decimal values for rate values are allowed | ||
app.post("/search", throttle({ "rate": "0.5/s", "burst": 5 }), function(req, res, next) { | ||
// ... | ||
}); | ||
``` | ||
By default, throttling is done on a per ip-address basis (respecting the X-Forwarded-For header). This can be configured by providing a custom key-function: | ||
```js | ||
var options = { | ||
"rate": "5/s", | ||
"burst": 10, | ||
"key": function(req) { | ||
return req.session.username; | ||
} | ||
}; | ||
app.post("/search", throttle(options), function(req, res, next) { | ||
// ... | ||
}); | ||
``` | ||
The "cost" per request can also be customized, making it possible to, for example whitelist certain requests: | ||
```js | ||
var whitelist = ["ip-1", "ip-2", ...]; | ||
var options = { | ||
"rate": "5/s", | ||
"burst": 10, | ||
"cost": function(req) { | ||
var ip_address = req.connection.remoteAddress; | ||
if (whitelist.indexOf(ip_address) >= 0) { | ||
return 0; | ||
} else if (req.session.is_privileged_user) { | ||
return 0.5; | ||
} else { | ||
return 1; | ||
} | ||
} | ||
}; | ||
app.post("/search", throttle(options), function(req, res, next) { | ||
// ... | ||
}); | ||
var options = { | ||
"rate": "1/s", | ||
"burst": 10, | ||
"cost": 2.5 // fixed costs are also supported | ||
}; | ||
app.post("/expensive", throttle(options), function(req, res, next) { | ||
// ... | ||
}); | ||
``` | ||
Throttled requests will simply be responded with an empty 429 response. This can be overridden: | ||
```js | ||
var options = { | ||
"rate": "5/s", | ||
"burst": 10, | ||
"on_throttled": function(req, res) { | ||
// Possible course of actions: | ||
// 1) Add client ip address to a ban list | ||
// 2) Send back more information | ||
res.set("X-Rate-Limit-Limit", 5); | ||
res.status(503).send("System overloaded, try again at a later time."); | ||
} | ||
}; | ||
``` | ||
Throttling can be applied across multiple processes. This requires an external storage mechanism which can be configured as follows: | ||
```js | ||
function ExternalStorage(connection_settings) { | ||
// ... | ||
} | ||
// These methods must be implemented | ||
ExternalStorage.prototype.get = function(key, callback) { | ||
fetch(key, function(entry) { | ||
// First argument should be null if no errors occurred | ||
callback(null, entry); | ||
}); | ||
} | ||
ExternalStorage.prototype.set = function(key, value, callback) { | ||
save(key, value, function(err) { | ||
// err should be null if no errors occurred | ||
callback(err); | ||
}); | ||
} | ||
var options = { | ||
"rate": "5/s", | ||
"store": new ExternalStorage() | ||
} | ||
app.post("/search", throttle(options), function(req, res, next) { | ||
// ... | ||
}); | ||
``` | ||
## Options | ||
`rate`: Determines the number of requests allowed within the specified time unit before subsequent requests get throttled. Must be specified according to the following format: *decimal/time-unit* | ||
*decimal*: A non-negative decimal value. This value is internally stored as a float, but scientific notation is not supported (e.g 10e2). | ||
*time-unit*: Any of the following: `s, sec, second, m, min, minute, h, hour, d, day` | ||
`burst`: The number of requests that can be made at any rate. The burst quota is refilled with the specified `rate`. | ||
`store`: Custom storage class. Must implement a `get` and `set` method with the following signatures: | ||
```js | ||
// callback in both methods must be called with an error (if any) as first argument | ||
function get(key, callback) { | ||
fetch(key, function(err, value) { | ||
if (err) callback(err); | ||
else callback(null, value); | ||
}); | ||
} | ||
function set(key, value, callback) { | ||
// value will be an object with the following structure: | ||
// { "tokens": Number, "accessed": Number } | ||
save(key, value, function(err) { | ||
callback(err); | ||
} | ||
} | ||
``` | ||
Defaults to an LRU cache with a maximum of 10000 entries. | ||
`key`: Function used to identify clients. It will be called with an [express request object](http://expressjs.com/en/4x/api.html#req). Defaults to: | ||
```js | ||
function(req) { | ||
return req.headers["x-forwarded-for"] || req.connection.remoteAddress; | ||
} | ||
``` | ||
`cost`: Number or function used to calculate the cost for a request. It will be called with an [express request object](http://expressjs.com/en/4x/api.html#req). Defaults to 1. | ||
`on_throttled`: A function called when the request is throttled. It will be called with an [express request object](http://expressjs.com/en/4x/api.html#req) and [express response object](http://expressjs.com/en/4x/api.html#res). Defaults to: | ||
```js | ||
function(req, res) { | ||
res.status(429).end(); | ||
}; | ||
``` |
@@ -10,2 +10,16 @@ "use strict"; | ||
function close_to(value, target, delta = 0.001) { | ||
return Math.abs(value - target) < delta; | ||
} | ||
function create_app() { | ||
var app = express(); | ||
app.get("/", throttle.apply(null, arguments), function(req, res) { | ||
res.status(200).json(req.connection.remoteAddress); | ||
}); | ||
return app; | ||
} | ||
test("fail to init...", t => { | ||
@@ -17,67 +31,111 @@ t.test("...without options", st => { | ||
t.test("...with options not being an object", st => { | ||
st.throws(() => throttle("options"), new Error); | ||
t.test("...with first argument not being a string or object", st => { | ||
st.throws(() => throttle(5), new Error); | ||
st.end(); | ||
}); | ||
t.test("...with 'burst' option not being a number", st => { | ||
st.throws(() => throttle("5"), new Error); | ||
t.test("...with empty option object", st => { | ||
st.throws(() => throttle({}), new Error); | ||
st.end(); | ||
}); | ||
t.test("...with 'rate' option not being a string", st => { | ||
st.throws(() => throttle(5, 10), new Error); | ||
t.test("...with 'key' not being a function", st => { | ||
st.throws(() => throttle({ "rate": "1/s", "burst": 5, "key": 1 }), new Error); | ||
st.end(); | ||
}); | ||
t.test("...without providing 'burst' option", st => { | ||
st.throws(() => throttle({ "rate": "1/s" }), new Error); | ||
t.test("...with 'cost' not being a number or function", st => { | ||
st.throws(() => throttle({ "rate": "1/s", "burst": 5, "cost": "5" }), new Error); | ||
st.end(); | ||
}); | ||
t.test("...without providing 'rate' option", st => { | ||
st.throws(() => throttle({ "burst": 5 }), new Error); | ||
t.test("...with 'on_throttled' not being a function", st => { | ||
st.throws(() => throttle({ "rate": "1/s", "burst": 5, "on_throttled": "test" }), new Error); | ||
st.end(); | ||
}); | ||
}); | ||
t.test("...with 'rate' not being in correct format", st => { | ||
st.throws(() => throttle(5, "x/hour"), new Error); | ||
test("init with...", t => { | ||
t.test("...rate", st => { | ||
st.doesNotThrow(() => throttle("1/s")); | ||
st.end(); | ||
}); | ||
t.test("...options object", st => { | ||
st.doesNotThrow(() => throttle({ | ||
"rate": "1/s", | ||
"burst": 5, | ||
"key": () => true, | ||
"cost": () => true, | ||
"on_throttled": () => true | ||
})); | ||
st.end(); | ||
}); | ||
}); | ||
test("successfully init with 'burst' and 'rate'", t => { | ||
t.doesNotThrow(() => throttle(5, "1/s")); | ||
t.end(); | ||
test("passthrough request...", t => { | ||
function verify(st) { | ||
return function(err, res) { | ||
st.equal(res.status, 200); | ||
st.end(); | ||
}; | ||
} | ||
t.test("...rate (integer)", st => { | ||
var app = create_app("1/s"); | ||
request(app).get("/").end(verify(st)); | ||
}); | ||
t.test("...rate (decimal)", st => { | ||
var app = create_app("1.0/s"); | ||
request(app).get("/").end(verify(st)); | ||
}); | ||
t.test("...delayed", st => { | ||
var app = create_app("1/s"); | ||
request(app).get("/").end(() => true); | ||
setTimeout(() => { | ||
request(app).get("/").end(verify(st)); | ||
}, 1000); | ||
}); | ||
}); | ||
test("make one request", t => { | ||
var app = express(); | ||
test("throttle request...", t => { | ||
function verify(st) { | ||
return function(err, res) { | ||
st.equal(res.status, 429); | ||
st.end(); | ||
}; | ||
} | ||
app.get("/", throttle(5, "1/s"), function(req, res) { | ||
res.status(200).end(); | ||
t.test("...rate (integer)", st => { | ||
var app = create_app("1/s"); | ||
request(app).get("/").end(() => true); | ||
request(app).get("/").end(verify(st)); | ||
}); | ||
request(app).get("/").end((err, res) => { | ||
t.equal(res.status, 200); | ||
t.end(); | ||
t.test("...rate (decimal)", st => { | ||
var app = create_app("1.0/s"); | ||
request(app).get("/").end(() => true); | ||
request(app).get("/").end(verify(st)); | ||
}); | ||
t.test("...delayed", st => { | ||
var app = create_app("1/s"); | ||
request(app).get("/").end(() => true); | ||
setTimeout(() => { | ||
request(app).get("/").end(verify(st)); | ||
}, 900); | ||
}); | ||
}); | ||
test("custom store + make one request", t => { | ||
var app = express(); | ||
test("custom store", t => { | ||
var store = new MemoryStore(); | ||
var app = create_app({ "rate": "1/s", "store": store }); | ||
app.get("/", throttle({ | ||
"burst": 5, | ||
"rate": "1/s", | ||
"store": store | ||
}), function(req, res) { | ||
res.status(200).json(req.connection.remoteAddress); | ||
}); | ||
request(app).get("/").end((err, res) => { | ||
t.equal(res.status, 200); | ||
store.get(res.body, (err, entry) => { | ||
t.equal(entry.tokens, 4); | ||
t.ok(entry); | ||
t.end(); | ||
@@ -89,21 +147,10 @@ }); | ||
test("respect x-forwarded-for header", t => { | ||
var app = express(); | ||
var store = new MemoryStore(); | ||
app.get("/", throttle({ | ||
"burst": 5, | ||
"rate": "1/s", | ||
"store": store | ||
}), function(req, res) { | ||
res.status(200).json(req.connection.remoteAddress); | ||
}); | ||
var proxy_ip = "123.123.123.123"; | ||
var app = create_app({ "rate": "1/s", "store": store }); | ||
request(app).get("/") | ||
.set("x-forwarded-for", proxy_ip) | ||
.end((err, res) => { | ||
request(app).get("/").set("x-forwarded-for", proxy_ip).end((err, res) => { | ||
t.equal(res.status, 200); | ||
store.get(proxy_ip, (err, entry) => { | ||
t.equal(entry.tokens, 4); | ||
t.ok(entry); | ||
t.end(); | ||
@@ -115,15 +162,8 @@ }); | ||
test("custom key function", t => { | ||
var app = express(); | ||
var store = new MemoryStore(); | ||
var custom_key = "custom_key"; | ||
app.get("/", throttle({ | ||
"burst": 5, | ||
var app = create_app({ | ||
"rate": "1/s", | ||
"store": store, | ||
"key": function() { | ||
return custom_key; | ||
} | ||
}), function(req, res) { | ||
res.status(200).end(); | ||
"key": function() { return custom_key; } | ||
}); | ||
@@ -134,3 +174,3 @@ | ||
store.get(custom_key, (err, entry) => { | ||
t.equal(entry.tokens, 4); | ||
t.ok(entry); | ||
t.end(); | ||
@@ -141,36 +181,66 @@ }); | ||
test("throttling", t => { | ||
var app = express(); | ||
app.get("/", throttle(5, "1/s"), function(req, res) { | ||
res.status(200).end(); | ||
test("custom cost value", t => { | ||
var store = new MemoryStore(); | ||
var app = create_app({ | ||
"rate": "1/s", | ||
"burst": 5, | ||
"store": store, | ||
"cost": 3 | ||
}); | ||
for (var i = 0; i < 5; ++i) { | ||
request(app).get("/").end(() => true); | ||
} | ||
request(app).get("/").end((err, res) => { | ||
t.equal(res.status, 429); | ||
t.end(); | ||
store.get(res.body, (err, entry) => { | ||
t.equal(res.status, 200); | ||
t.assert(close_to(entry.tokens, 2)); | ||
t.end(); | ||
}); | ||
}); | ||
}); | ||
test("custom on_throttled function", t => { | ||
test("custom cost function", t => { | ||
var app = express(); | ||
app.get("/", throttle({ | ||
var store = new MemoryStore(); | ||
app.get("/:admin", throttle({ | ||
"burst": 5, | ||
"rate": "1/s", | ||
"on_throttled": function(req, res) { | ||
res.status(429).json("slow down!"); | ||
"store": store, | ||
"cost": function(req) { | ||
if (req.params.admin == "yes") { | ||
return 0; | ||
} else { | ||
return 3; | ||
} | ||
} | ||
}), function(req, res) { | ||
res.status(200).end(); | ||
res.status(200).json(req.connection.remoteAddress); | ||
}); | ||
for (var i = 0; i < 5; ++i) { | ||
request(app).get("/").end(() => true); | ||
} | ||
request(app).get("/yes").end((err, res) => { | ||
store.get(res.body, (err, entry) => { | ||
t.equal(res.status, 200); | ||
t.assert(close_to(entry.tokens, 5)); | ||
request(app).get("/no").end((err, res) => { | ||
store.get(res.body, (err, entry) => { | ||
t.equal(res.status, 200); | ||
t.assert(close_to(entry.tokens, 2)); | ||
t.end(); | ||
}); | ||
}); | ||
}); | ||
}); | ||
}); | ||
test("custom on_throttled function", t => { | ||
var app = create_app({ | ||
"rate": "1/s", | ||
"on_throttled": function(req, res) { | ||
res.status(503).json("slow down!"); | ||
} | ||
}); | ||
request(app).get("/").end(() => true); | ||
request(app).get("/").end((err, res) => { | ||
t.equal(res.status, 429); | ||
t.equal(res.status, 503); | ||
t.equal(res.body, "slow down!"); | ||
@@ -177,0 +247,0 @@ t.end(); |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
17839
360
0
175