express-throttle
Advanced tools
+70
-36
| "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 { |
+1
-1
| { | ||
| "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
-0
| # 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(); | ||
| }; | ||
| ``` |
+146
-76
@@ -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(); |
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
85.67%360
33.83%0
-100%175
5733.33%