From 42905cecef9b38a9a3036761f6aa8ef796263524 Mon Sep 17 00:00:00 2001 From: Glen Keane Date: Fri, 8 Jul 2016 15:04:14 +0100 Subject: [PATCH] added rate limiting options --- README.md | 13 ++++- autocannon.js | 2 + help.txt | 11 +++- lib/httpClient.js | 33 ++++++++--- lib/run.js | 134 ++++++++++++++++++++++--------------------- test/runRate.test.js | 32 +++++++++++ 6 files changed, 149 insertions(+), 76 deletions(-) create mode 100644 test/runRate.test.js diff --git a/README.md b/README.md index bb76413a..ba2ae05a 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,16 @@ Available options: -B/--bailout NUM The number of failures before initiating a bailout. -M/--maxConnectionRequests NUM - The max number of requests to make per connection to the server + The max number of requests to make per connection to the server. -O/--maxOverallRequests NUM - The max number of requests to make overall to the server + The max number of requests to make overall to the server. + -r/--connectionRate NUM + The max number of requests to make per second from an individual connection. + -R/--overallRate NUM + The max number of requests to make per second from an all connections. + connection rate will take precedence if both are set. + NOTE: if using rate limiting and a very large rate is entered which cannot be met, + Autocannon will do as many requests as possible per second. -n/--no-progress Don't render the progress bar. default: false. -l/--latency @@ -119,6 +126,8 @@ Start autocannon against the given target. * `setupClient`: A `Function` which will be passed the `Client` object for each connection to be made. This can be used to customise each individual connection headers and body using the API shown below. The changes you make to the client in this function will take precedence over the default `body` and `headers` you pass in here. There is an example of this in the samples folder. _OPTIONAL_ default: `function noop () {}`. * `maxConnectionRequests`: A `Number` stating the max requests to make per connection. `amount` takes precedence if both are set. _OPTIONAL_ * `maxOverallRequests`: A `Number` stating the max requests to make overall. Can't be less than `connections`. `maxConnectionRequests` takes precedence if both are set. _OPTIONAL_ + * `connectionRate`: A `Number` stating the rate of requests to make per second from each individual connection. No rate limiting by default. _OPTIONAL_ + * `overallRate`: A `Number` stating the rate of requests to make per second from all connections. `conenctionRate` takes precedence if both are set. No rate limiting by default. _OPTIONAL_ * `requests`: An `Array` of `Object`s which represents the sequence of requests to make while benchmarking. Can be used in conjunction with the `body`, `headers` and `method` params above. The `Object`s in this array can have `body`, `headers`, `method`, or `path` attributes, which overwrite those that are passed in this `opts` object. Therefore, the ones in this (`opts`) object take precedence and should be viewed as defaults. Check the samples folder for an example of how this might be used. _OPTIONAL_. * `cb`: The callback which is called on completion of the benchmark. Takes the following params. _OPTIONAL_. * `err`: If there was an error encountered with the run. diff --git a/autocannon.js b/autocannon.js index 508da978..d50e6411 100755 --- a/autocannon.js +++ b/autocannon.js @@ -30,6 +30,8 @@ function start () { input: 'i', maxConnectionRequests: 'M', maxOverallRequests: 'O', + connectionRate: 'r', + overallRate: 'R', renderProgressBar: 'progress', title: 'T', version: 'v', diff --git a/help.txt b/help.txt index 9d952430..c3dbcd4e 100644 --- a/help.txt +++ b/help.txt @@ -27,9 +27,16 @@ Available options: -B/--bailout NUM The number of failures before initiating a bailout. -M/--maxConnectionRequests NUM - The max number of requests to make per connection to the server + The max number of requests to make per connection to the server. -O/--maxOverallRequests NUM - The max number of requests to make overall to the server + The max number of requests to make overall to the server. + -r/--connectionRate NUM + The max number of requests to make per second from an individual connection. + -R/--overallRate NUM + The max number of requests to make per second from an all connections. + connection rate will take precedence if both are set. + NOTE: if using rate limiting and a very large rate is entered which cannot be met, + Autocannon will do as many requests as possible per second. -n/--no-progress Don't render the progress bar. default: false. -l/--latency diff --git a/lib/httpClient.js b/lib/httpClient.js index 4379d843..efb9dfb2 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -22,9 +22,15 @@ function Client (opts) { if (this.secure && this.opts.port === 80) this.opts.port = 443 this.parser = new HTTPParser(HTTPParser.RESPONSE) this.requestIterator = new RequestIterator(opts.requests, opts) + + // used for request limiting this.reqsMade = 0 this.responseMax = opts.responseMax + // used for rate limiting + this.reqsMadeThisSecond = 0 + this.rate = opts.rate + this.resData = new Array(opts.pipelining) for (var i = 0; i < this.resData.length; i++) { this.resData[i] = { @@ -52,6 +58,14 @@ function Client (opts) { this._connect() } + if (this.rate) { + this.rateInterval = setInterval(() => { + this.reqsMadeThisSecond = 0 + if (this.paused) this._doRequest(this.cer) + this.paused = false + }, 1000) + } + this.timeoutTicker = retimer(handleTimeout, this.timeout) this.parser[HTTPParser.kOnHeaders] = () => {} this.parser[HTTPParser.kOnHeadersComplete] = (opts) => { @@ -71,8 +85,6 @@ function Client (opts) { this.cer = this.cer === opts.pipelining - 1 ? 0 : this.cer++ this._doRequest(this.cer) - - this.timeoutTicker.reschedule(this.timeout) } this._connect() @@ -108,12 +120,18 @@ Client.prototype._connect = function () { // rpi = request pipelining index Client.prototype._doRequest = function (rpi) { - if (!this.destroyed && this.responseMax && this.reqsMade++ >= this.responseMax) { - this.destroy() - return + if (!this.rate || (this.rate && this.reqsMadeThisSecond++ < this.rate)) { + if (!this.destroyed && this.responseMax && this.reqsMade++ >= this.responseMax) { + this.destroy() + return + } + + this.resData[rpi].startTime = process.hrtime() + this.conn.write(this.requestIterator.move()) + this.timeoutTicker.reschedule(this.timeout) + } else { + this.paused = true } - this.resData[rpi].startTime = process.hrtime() - this.conn.write(this.requestIterator.move()) } Client.prototype._destroyConnection = function () { @@ -128,6 +146,7 @@ Client.prototype.destroy = function () { if (!this.destroyed) { this.destroyed = true this.timeoutTicker.clear() + if (this.rate) clearInterval(this.rateInterval) this.emit('done') this._destroyConnection() } diff --git a/lib/run.js b/lib/run.js index 289fb56a..cec31dfd 100644 --- a/lib/run.js +++ b/lib/run.js @@ -18,6 +18,8 @@ const defaultOptions = { timeout: 10, maxConnectionRequests: 0, maxOverallRequests: 0, + connectionRate: 0, + overallRate: 0, amount: 0, requests: [{}] } @@ -40,66 +42,10 @@ function run (opts, cb) { opts = xtend(defaultOptions, opts) - if (!opts.url) { - cb(new Error('url option required')) - return - } - - if (typeof opts.duration !== 'number') { - if (/[a-zA-Z]/.exec(opts.duration)) opts.duration = timestring(opts.duration) - else opts.duration = Number(opts.duration.trim()) - } - - if (typeof opts.duration === 'number') { - if (opts.duration <= 0) { - cb(new Error('duration must be greater than 0')) - return - } - } else { - cb(new Error('duration entered was in an invalid format')) - return - } + // do error checking, if error, return + if (checkOptsForErrors()) return - if (opts.connections < 1) { - cb(new Error('connections factor can not be < 1')) - return - } - - if (opts.pipelining < 1) { - cb(new Error('pipelining factor can not be < 1')) - return - } - - if (opts.timeout <= 0) { - cb(new Error('timeout must be greater than 0')) - return - } - - if (opts.bailout && opts.bailout < 1) { - cb(new Error('bailout threshold can not be < 1')) - return - } - - if (opts.amount) { - if (opts.amount < 1) { - cb(new Error('amount can not be < 1')) - return - } - } - - if (opts.maxConnectionRequests && opts.maxConnectionRequests < 1) { - cb(new Error('maxConnectionRequests can not be < 1')) - return - } - - if (opts.maxOverallRequests) { - if (opts.maxOverallRequests < 1) { - cb(new Error('maxOverallRequests can not be < 1')) - return - } - } - - // set it down here, so throwing over invalid opts and setting defaults etc. + // set tracker.opts here, so throwing over invalid opts and setting defaults etc. // is done tracker.opts = opts @@ -111,7 +57,7 @@ function run (opts, cb) { let timeouts = 0 let totalBytes = 0 let totalRequests = 0 - let amount = opts.amount === Infinity ? 0 : opts.amount + let amount = opts.amount let stop = false let numRunning = opts.connections let startTime = Date.now() @@ -125,18 +71,20 @@ function run (opts, cb) { url.setupClient = opts.setupClient url.timeout = opts.timeout url.requests = opts.requests - url.responseMax = amount || opts.maxConnectionRequests || opts.maxOverallRequests + url.rate = opts.connectionRate || opts.overallRate let clients = [] for (let i = 0; i < opts.connections; i++) { - if (!amount && !opts.maxConnectionRequests && opts.maxOverallRequests && - typeof opts.maxOverallRequests === 'number') { + if (!amount && !opts.maxConnectionRequests && opts.maxOverallRequests) { url.responseMax = distributeNums(opts.maxOverallRequests, i) } - if (amount && typeof amount === 'number') { + if (amount) { url.responseMax = distributeNums(amount, i) } + if (!opts.connectionRate && opts.overallRate) { + url.rate = distributeNums(opts.overallRate, i) + } let client = new Client(url) client.on('response', onResponse) @@ -224,8 +172,64 @@ function run (opts, cb) { } }, 1000) + // will return true if error with opts entered + function checkOptsForErrors () { + if (!opts.url) { + cb(new Error('url option required')) + return true + } + + if (typeof opts.duration !== 'number') { + if (/[a-zA-Z]/.exec(opts.duration)) opts.duration = timestring(opts.duration) + else opts.duration = Number(opts.duration.trim()) + } + + if (typeof opts.duration === 'number') { + if (lessThanZeroError(opts.duration, 'duration')) return true + } else { + cb(new Error('duration entered was in an invalid format')) + return true + } + + if (lessThanOneError(opts.connections, 'connections')) return true + if (lessThanOneError(opts.pipelining, 'pipelining factor')) return true + if (greaterThanZeroError(opts.timeout, 'timeout')) return true + if (opts.bailout && lessThanOneError(opts.bailout, 'bailout threshold')) return true + if (opts.connectionRate && lessThanOneError(opts.connectionRate, 'connectionRate')) return true + if (opts.overallRate && lessThanOneError(opts.overallRate, 'bailout overallRate')) return true + if (opts.amount && lessThanOneError(opts.amount, 'amount')) return true + if (opts.maxConnectionRequests && lessThanOneError(opts.maxConnectionRequests, 'maxConnectionRequests')) return true + if (opts.maxOverallRequests && lessThanOneError(opts.maxOverallRequests, 'maxOverallRequests')) return true + + function lessThanZeroError (x, label) { + if (x < 0) { + cb(new Error(`${label} can not be less than 0`)) + return true + } + return false + } + + function lessThanOneError (x, label) { + if (x < 1) { + cb(new Error(`${label} can not be less than 1`)) + return true + } + return false + } + + function greaterThanZeroError (x, label) { + if (x <= 0) { + cb(new Error(`${label} must be greater than 0`)) + return true + } + return false + } + + return false + } // checkOptsForErrors + return tracker -} +} // run function histAsObj (hist, total) { const result = { diff --git a/test/runRate.test.js b/test/runRate.test.js new file mode 100644 index 00000000..318f6a23 --- /dev/null +++ b/test/runRate.test.js @@ -0,0 +1,32 @@ +'use strict' + +const test = require('tap').test +const run = require('../lib/run') +const helper = require('./helper') +const server = helper.startServer() + +test('run should only send the expected number of requests per second', (t) => { + t.plan(6) + + run({ + url: `http://localhost:${server.address().port}`, + connections: 2, + overallRate: 10, + amount: 40 + }, (err, res) => { + t.error(err) + t.equal(res.duration, 4, 'should have take 4 seconds to send 10 requests per seconds') + t.equal(res.requests.average, 10, 'should have sent 10 requests per second on average') + }) + + run({ + url: `http://localhost:${server.address().port}`, + connections: 2, + connectionRate: 10, + amount: 40 + }, (err, res) => { + t.error(err) + t.equal(res.duration, 2, 'should have taken 2 seconds to send 10 requests per connection with 2 connections') + t.equal(res.requests.average, 20, 'should have sent 20 requests per second on average with two connections') + }) +})