├── .gitignore ├── index.js ├── package.json ├── test ├── integration_test.js ├── requestset_test.js ├── pool_test.js └── endpoint_test.js ├── LICENSE ├── lib ├── pinger.js ├── request_set.js ├── pool.js └── endpoint.js └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var inherits = require('util').inherits, 2 | EventEmitter = require('events').EventEmitter, 3 | Pinger = require('./lib/pinger')(inherits, EventEmitter), 4 | Endpoint = require('./lib/endpoint')(inherits, EventEmitter, Pinger), 5 | RequestSet = require('./lib/request_set') 6 | 7 | module.exports = require('./lib/pool')(inherits, EventEmitter, Endpoint, RequestSet) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "poolee", 3 | "version": "0.4.8", 4 | "description": "HTTP pool and load balancer", 5 | "homepage": "http://github.com/dannycoates/poolee", 6 | "author": "Danny Coates ", 7 | "keywords": ["pool", "http", "retry", "health", "load balancer"], 8 | "repository": { 9 | "type": "git", 10 | "url": "http://github.com/dannycoates/poolee.git" 11 | }, 12 | "engines" : { "node": ">=0.6.0" }, 13 | "dependencies": { 14 | "keep-alive-agent": "git://github.com/Voxer/keep-alive-agent.git#v0.0.1" 15 | }, 16 | "devDependencies": { 17 | "mocha": "*" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/integration_test.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert") 2 | var Pool = require('../index') 3 | var http = require('http') 4 | 5 | var noop = function () {} 6 | 7 | describe('Pool', function () { 8 | var pool 9 | 10 | beforeEach(function () { 11 | pool = new Pool(http, ['127.0.0.1:6969']) 12 | }) 13 | 14 | // 15 | // request 16 | // 17 | ////////////////////////////////////////////////////////////////////////////// 18 | 19 | describe("request()", function () { 20 | 21 | it("passes options all the way to the endpoint request", function (done) { 22 | var s = http.createServer(function (req, res) { 23 | res.end("foo") 24 | s.close() 25 | }) 26 | s.on('listening', function () { 27 | pool.request({ 28 | path: '/foo', 29 | method: 'GET', 30 | ca: 'bar.ca' 31 | }, null, function (e, r, b) { 32 | done() 33 | }) 34 | var req = pool.get_node().requests[0] 35 | assert.equal(req.options.ca, 'bar.ca') 36 | }) 37 | s.listen(6969) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Danny Coates 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. -------------------------------------------------------------------------------- /lib/pinger.js: -------------------------------------------------------------------------------- 1 | module.exports = function (inherits, EventEmitter) { 2 | 3 | function Pinger(request) { 4 | this.onPingResponse = pingResponse.bind(this) 5 | this.request = request.bind(this, this.onPingResponse) 6 | this.running = false 7 | this.attempts = 0 8 | EventEmitter.call(this) 9 | } 10 | inherits(Pinger, EventEmitter) 11 | 12 | function pingResponse(error, response, body) { 13 | if (!error && response.statusCode === 200) { 14 | this.emit('pong') 15 | this.running = false 16 | } 17 | else { 18 | this.attempts++ 19 | this.ping() 20 | } 21 | } 22 | 23 | function exponentialBackoff(attempt) { 24 | return Math.min( 25 | Math.floor(Math.random() * Math.pow(2, attempt) + 10), 26 | 10000) 27 | } 28 | 29 | Pinger.prototype.ping = function () { 30 | if (this.attempts) { 31 | setTimeout(this.request, exponentialBackoff(this.attempts)) 32 | } 33 | else { 34 | this.request() 35 | } 36 | } 37 | 38 | Pinger.prototype.start = function () { 39 | if (!this.running) { 40 | this.running = true 41 | this.attempts = 0 42 | this.ping() 43 | } 44 | } 45 | 46 | return Pinger 47 | } 48 | -------------------------------------------------------------------------------- /lib/request_set.js: -------------------------------------------------------------------------------- 1 | var Stream = require('stream') 2 | 3 | // An object to track server requests and handle retries 4 | // 5 | // pool: a pool of endpoints 6 | // options: { 7 | // attempts: number of tries 8 | // maxHangups: number of 'socket hang ups' before giving up (2) 9 | // maxAborts: number of 'aborted' before giving up (2) 10 | // retryDelay: minimum ms to wait before first retry using exponential backoff (20) 11 | // } 12 | // callback: function (err, response, body) {} 13 | function RequestSet(pool, options, callback) { 14 | this.options = options || {} 15 | this.pool = pool 16 | this.callback = callback 17 | 18 | this.attemptsLeft = attemptsFu(options, pool) 19 | this.attempts = this.attemptsLeft 20 | 21 | this.maxHangups = options.maxHangups || 2 22 | this.hangups = 0 23 | 24 | this.maxAborts = options.maxAborts || 2 25 | this.aborts = 0 26 | 27 | if (!options.retryDelay && options.retryDelay !== 0) { 28 | options.retryDelay = 20 29 | } 30 | this.delay = options.retryDelay 31 | } 32 | 33 | function attemptsFu(options, pool) { 34 | if (options.data instanceof Stream) { 35 | return 1 36 | } 37 | return options.attempts || Math.min(pool.options.maxRetries + 1, Math.max(pool.length, 2)) 38 | } 39 | 40 | function exponentialBackoff(attempt, delay) { 41 | return Math.random() * Math.pow(2, attempt) * delay 42 | } 43 | 44 | // this = RequestSet 45 | function handleResponse(err, response, body) { 46 | this.attemptsLeft-- 47 | if (err) { 48 | var delay = (err.delay === true) 49 | ? exponentialBackoff(this.attempts - this.attemptsLeft, this.delay) 50 | : err.delay 51 | 52 | if (err.reason === "socket hang up") { this.hangups++ } 53 | else if (err.reason === "aborted") { this.aborts++ } 54 | 55 | if (this.attemptsLeft > 0 && this.hangups < 2 && this.aborts < 2) { 56 | this.pool.onRetry(err) 57 | if (delay > 0) { 58 | setTimeout(this.doRequest.bind(this), delay) 59 | } else { 60 | this.doRequest() 61 | } 62 | return 63 | } 64 | } 65 | if (this.callback) { 66 | this.callback(err, response, body) 67 | this.callback = null 68 | } 69 | } 70 | 71 | // An http(s) request that might be retried 72 | // 73 | // pool: a pool of endpoints 74 | // options: { 75 | // attempts: number of tries 76 | // timeout: request timeout in ms 77 | // maxHangups: number of 'socket hang ups' before giving up (2) 78 | // maxAborts: number of 'aborted' before giving up (2) 79 | // retryDelay: minimum ms to wait before first retry using exponential backoff (20) 80 | // } 81 | // callback: function (err, response, body) {} 82 | RequestSet.request = function (pool, options, callback) { 83 | var set = new RequestSet(pool, options, callback) 84 | set.doRequest() 85 | } 86 | 87 | RequestSet.prototype.doRequest = function () { 88 | var node = this.pool.get_node() 89 | node.request(this.options, handleResponse.bind(this)) 90 | } 91 | 92 | module.exports = RequestSet 93 | 94 | -------------------------------------------------------------------------------- /test/requestset_test.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert") 2 | var RequestSet = require("../lib/request_set") 3 | 4 | var node = { 5 | request: function () {} 6 | } 7 | 8 | var unhealthy = { 9 | request: function (options, callback) { callback({ message: 'no nodes'}) } 10 | } 11 | 12 | function succeeding_request(options, cb) { 13 | return cb(null, {}, "foo") 14 | } 15 | 16 | function failing_request(options, cb) { 17 | return cb({ 18 | message: "crap", 19 | reason: "ihateyou" 20 | }) 21 | } 22 | 23 | function hangup_request(options, cb) { 24 | return cb({ 25 | message: "hang up", 26 | reason: "socket hang up" 27 | }) 28 | } 29 | 30 | function aborted_request(options, cb) { 31 | return cb({ 32 | message: "aborted", 33 | reason: "aborted" 34 | }) 35 | } 36 | 37 | var pool = { 38 | options: { maxRetries: 5 }, 39 | get_node: function () { 40 | return node 41 | }, 42 | onRetry: function () {}, 43 | length: 3 44 | } 45 | 46 | describe("RequestSet", function () { 47 | 48 | it("defaults attempt count to at least 2", function () { 49 | var r = new RequestSet({length: 1, options: { maxRetries: 5 }}, {}, null) 50 | assert.equal(r.attempts, 2) 51 | }) 52 | 53 | it("defaults attempt count to at most maxRetries + 1", function () { 54 | var r = new RequestSet({length: 9, options: { maxRetries: 4 }}, {}, null) 55 | assert.equal(r.attempts, 5) 56 | }) 57 | 58 | it("defaults attempt count to pool.length", function () { 59 | var r = new RequestSet({length: 4, options: { maxRetries: 5 }}, {}, null) 60 | assert.equal(r.attempts, 4) 61 | }) 62 | 63 | describe("request()", function () { 64 | 65 | it("calls the callback on success", function (done) { 66 | node.request = succeeding_request 67 | RequestSet.request(pool, {}, function (err, res, body) { 68 | assert.equal(err, null) 69 | assert.equal(body, "foo") 70 | done() 71 | }) 72 | }) 73 | 74 | it("calls the callback on error", function (done) { 75 | node.request = failing_request 76 | RequestSet.request(pool, {}, function (err, res, body) { 77 | assert.equal(err.message, "crap") 78 | done() 79 | }) 80 | }) 81 | 82 | it("calls the callback with a 'no nodes' error when there's no nodes to service the request", function (done) { 83 | var p = { 84 | options: { maxRetries: 5 }, 85 | get_node: function () { return unhealthy }, 86 | length: 0, 87 | onRetry: function () {} 88 | } 89 | RequestSet.request(p, {}, function (err, res, body) { 90 | assert.equal(err.message, "no nodes") 91 | done() 92 | }) 93 | }) 94 | 95 | it("retries hangups once", function (done) { 96 | var p = { 97 | i: 0, 98 | options: { maxRetries: 5 }, 99 | get_node: function () { return this.nodes[this.i++]}, 100 | onRetry: function () {}, 101 | length: 2, 102 | nodes: [{ request: hangup_request }, { request: succeeding_request }] 103 | } 104 | RequestSet.request(p, {}, function (err, res, body) { 105 | assert.equal(err, null) 106 | assert.equal(body, "foo") 107 | done() 108 | }) 109 | }) 110 | 111 | it("retries hangups once then fails", function (done) { 112 | var p = { 113 | i: 0, 114 | options: { maxRetries: 5 }, 115 | get_node: function () { return this.nodes[this.i++]}, 116 | onRetry: function () {}, 117 | length: 3, 118 | nodes: [{ request: hangup_request }, { request: hangup_request }, { request: succeeding_request }] 119 | } 120 | RequestSet.request(p, {}, function (err, res, body) { 121 | assert.equal(err.reason, "socket hang up") 122 | done() 123 | }) 124 | }) 125 | 126 | it("retries aborts once", function (done) { 127 | var p = { 128 | i: 0, 129 | options: { maxRetries: 5 }, 130 | get_node: function () { return this.nodes[this.i++]}, 131 | onRetry: function () {}, 132 | length: 2, 133 | nodes: [{ request: aborted_request }, { request: succeeding_request }] 134 | } 135 | RequestSet.request(p, {}, function (err, res, body) { 136 | assert.equal(err, null) 137 | assert.equal(body, "foo") 138 | done() 139 | }) 140 | }) 141 | 142 | it("retries aborts once then fails", function (done) { 143 | var p = { 144 | i: 0, 145 | options: { maxRetries: 5 }, 146 | get_node: function () { return this.nodes[this.i++]}, 147 | onRetry: function () {}, 148 | length: 3, 149 | nodes: [{ request: aborted_request }, { request: aborted_request }, { request: succeeding_request }] 150 | } 151 | RequestSet.request(p, {}, function (err, res, body) { 152 | assert.equal(err.reason, "aborted") 153 | done() 154 | }) 155 | }) 156 | 157 | it("retries up to this.attempts times", function (done) { 158 | var p = { 159 | i: 0, 160 | options: { maxRetries: 5 }, 161 | get_node: function () { return this.nodes[this.i++]}, 162 | onRetry: function () {}, 163 | length: 3, 164 | nodes: [{ request: failing_request }, { request: failing_request }, { request: aborted_request }] 165 | } 166 | RequestSet.request(p, {}, function (err, res, body) { 167 | assert.equal(err.reason, "aborted") 168 | done() 169 | }) 170 | }) 171 | 172 | it("retries up to the first success", function (done) { 173 | var p = { 174 | i: 0, 175 | options: { maxRetries: 5 }, 176 | get_node: function () { return this.nodes[this.i++]}, 177 | onRetry: function () {}, 178 | length: 4, 179 | nodes: [{ request: failing_request }, { request: failing_request }, { request: succeeding_request }, { request: failing_request }] 180 | } 181 | RequestSet.request(p, {}, function (err, res, body) { 182 | assert.equal(err, null) 183 | assert.equal(body, "foo") 184 | done() 185 | }) 186 | }) 187 | }) 188 | }) 189 | -------------------------------------------------------------------------------- /lib/pool.js: -------------------------------------------------------------------------------- 1 | module.exports = function (inherits, EventEmitter, Endpoint, RequestSet) { 2 | 3 | ////////////////////////////////////// 4 | // 5 | // Pool 6 | // 7 | // nodes: array of strings formatted like 'ip:port' 8 | // 9 | // options: 10 | // { 11 | // maxPending: number of pending requests allowed (1000) 12 | // ping: ping path (default = no ping checks) 13 | // pingTimeout: number (milliseconds) default 2000 14 | // retryFilter: function (response) { return true to reject response and retry } 15 | // retryDelay: number (milliseconds) default 20 16 | // keepAlive: use an alternate Agent that does keep-alive properly (boolean) default false 17 | // name: string (optional) 18 | // maxRetries: number (default = 5) 19 | // agentOptions: {} an object for passing options directly to the Http Agent 20 | // } 21 | function Pool(http, nodes, options) { 22 | options = options || {} 23 | if (!http || !http.request || !http.Agent) { 24 | throw new Error('invalid http module') 25 | } 26 | 27 | options.retryFilter = options.retryFilter || options.retry_filter 28 | options.retryDelay = options.retryDelay || options.retry_delay 29 | options.ping = options.ping || options.path 30 | options.maxRetries = options.maxRetries === 0 ? 0 : options.maxRetries || 5 31 | 32 | if (!options.retryDelay && options.retryDelay !== 0) { 33 | options.retryDelay = 20 34 | } 35 | 36 | this.name = options.name 37 | this.options = options 38 | this.maxPending = options.maxPending || 1000 39 | this.nodes = [] 40 | if (Array.isArray(nodes)) { 41 | for (var i = 0; i < nodes.length; i++) { 42 | var ip_port = nodes[i].split(':') 43 | var ip = ip_port[0] 44 | var port = +ip_port[1] 45 | if (port > 0 && port < 65536) { 46 | var node = new Endpoint(http, ip, port, options) 47 | node.on('health', node_health_changed.bind(this)) 48 | node.on('timeout', node_timed_out.bind(this)) 49 | this.nodes.push(node) 50 | } 51 | } 52 | } 53 | 54 | if (this.nodes.length === 0) { 55 | throw new Error('no valid nodes') 56 | } 57 | this.length = this.nodes.length 58 | } 59 | inherits(Pool, EventEmitter) 60 | 61 | // Bound handlers 62 | 63 | function node_health_changed(node) { 64 | this.emit('health', node.ip + ':' + node.port + ' health: ' + node.healthy) 65 | } 66 | 67 | function node_timed_out(request) { 68 | this.emit('timeout', request.node.ip + ':' + request.node.port + request.options.path) 69 | } 70 | 71 | // returns an array of healthy Endpoints 72 | Pool.prototype.healthy_nodes = function () { 73 | var healthy = [], len = this.nodes.length 74 | for (var i = 0; i < len; i++) { 75 | var n = this.nodes[i] 76 | if (n.healthy) { 77 | healthy.push(n) 78 | } 79 | } 80 | return healthy 81 | } 82 | Pool.prototype.healthyNodes = Pool.prototype.healthy_nodes 83 | 84 | Pool.prototype.onRetry = function (err) { 85 | this.emit('retrying', err) 86 | } 87 | 88 | function optionsFu(options) { 89 | return (typeof options === 'string') ? { path: options } : (options || {}) 90 | } 91 | 92 | // options: 93 | // { 94 | // path: string 95 | // method: ['POST', 'GET', 'PUT', 'DELETE', 'HEAD'] (GET) 96 | // retryFilter: function (response) { return true to reject response and retry } 97 | // attempts: number (optional, default = nodes.length) 98 | // retryDelay: number (milliseconds) default Pool.retry_delay 99 | // timeout: request timeout in ms 100 | // encoding: response body encoding (utf8) 101 | // stream: stream instead of buffer response body (default based on callback) 102 | // } 103 | // 104 | // data: string, buffer, or stream (optional) 105 | // 106 | // callback: 107 | // function(err, res, body) {} 108 | // function(err, res) {} 109 | Pool.prototype.request = function (options, data, callback) { 110 | var self = this 111 | options = optionsFu(options) 112 | 113 | if (!options.data && (typeof data === 'string' || Buffer.isBuffer(data))) { 114 | options.data = data 115 | } 116 | else if (typeof data === 'function') { 117 | callback = data 118 | } 119 | 120 | options.method = options.method || 'GET' 121 | 122 | options.retryDelay = options.retryDelay || options.retry_delay 123 | if (!options.retryDelay && options.retryDelay !== 0) { 124 | options.retryDelay = this.options.retryDelay 125 | } 126 | 127 | options.retryFilter = options.retryFilter || options.retry_filter 128 | if (!options.retryFilter) { 129 | options.retryFilter = this.options.retryFilter 130 | } 131 | options.stream = (options.stream === undefined) ? callback.length === 2 : options.stream 132 | 133 | var started = Date.now() 134 | RequestSet.request(this, options, function (err, res, body) { 135 | options.success = !err 136 | options.reused = res && res.socket && (res.socket._requestCount || 1) > 1 137 | self.emit('timing', Date.now() - started, options) 138 | callback(err, res, body) 139 | }) 140 | } 141 | 142 | Pool.prototype.get = Pool.prototype.request 143 | 144 | Pool.prototype.put = function (options, data, callback) { 145 | options = optionsFu(options) 146 | options.method = 'PUT' 147 | return this.request(options, data, callback) 148 | } 149 | 150 | Pool.prototype.post = function (options, data, callback) { 151 | options = optionsFu(options) 152 | options.method = 'POST' 153 | return this.request(options, data, callback) 154 | } 155 | 156 | Pool.prototype.del = function (options, callback) { 157 | options = optionsFu(options) 158 | options.method = 'DELETE' 159 | options.agent = false // XXX 160 | return this.request(options, callback) 161 | } 162 | 163 | Pool.prototype.stats = function () { 164 | var stats = [] 165 | var len = this.nodes.length 166 | for (var i = 0; i < len; i++) { 167 | var node = this.nodes[i] 168 | stats.push(node.stats()) 169 | } 170 | return stats 171 | } 172 | 173 | Pool.prototype.get_node = function () { 174 | var len = this.nodes.length 175 | var h = [] 176 | var sum = 0 177 | var totalPending = 0 178 | var r = Math.floor(Math.random() * len) 179 | for (var i = 0; i < len; i++) { 180 | r = (r + 1) % len 181 | var node = this.nodes[r] 182 | if (node.ready()) { 183 | return node //fast path 184 | } 185 | else if (node.healthy) { 186 | h.push(node) 187 | sum += node.pending 188 | } 189 | totalPending += node.pending 190 | } 191 | if (totalPending >= this.maxPending) { 192 | return Endpoint.overloaded() 193 | } 194 | var avg = sum / h.length 195 | while (h.length) { 196 | var node = h.pop() 197 | if (node.pending <= avg) { 198 | return node 199 | } 200 | } 201 | return Endpoint.unhealthy() 202 | } 203 | 204 | Pool.prototype.getNode = Pool.prototype.get_node //must keep the old _ api 205 | 206 | Pool.prototype.pending = function () { 207 | return this.nodes.reduce(function (a, b) { return a + b.pending }, 0) 208 | } 209 | 210 | Pool.prototype.rate = function () { 211 | return this.nodes.reduce(function (a, b) { return a + b.requestRate }, 0) 212 | } 213 | 214 | Pool.prototype.requestCount = function () { 215 | return this.nodes.reduce(function (a, b) { return a + b.requestCount }, 0) 216 | } 217 | 218 | return Pool 219 | } 220 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # poolee 2 | 3 | HTTP pool and load balancer for node. 4 | 5 | # Example 6 | 7 | ```javascript 8 | 9 | var Pool = require("poolee") 10 | var http = require("http") 11 | 12 | var servers = 13 | ["127.0.0.1:8886" 14 | ,"127.0.0.1:8887" 15 | ,"127.0.0.1:8888" 16 | ,"127.0.0.1:8889"] 17 | 18 | var postData = '{"name":"Danny Coates"}' 19 | 20 | var pool = new Pool(http, servers, options) 21 | 22 | pool.request( 23 | { method: "PUT" 24 | , path: "/users/me" 25 | } 26 | , postData 27 | , function (error, response, body) { 28 | if (error) { 29 | console.error(error.message) 30 | return 31 | } 32 | if(response.statusCode === 201) { 33 | console.log("put succeeded") 34 | } 35 | else { 36 | console.log(response.statusCode) 37 | console.log(body) 38 | } 39 | } 40 | ) 41 | ``` 42 | 43 | --- 44 | 45 | # API 46 | 47 | ## Pool 48 | 49 | ### new 50 | 51 | ```javascript 52 | var Pool = require('poolee') 53 | //... 54 | 55 | var pool = new Pool( 56 | http // the http module to use (require('http') or require('https')) 57 | , 58 | [ "127.0.0.1:1337" // array of endpoints in "host:port" form 59 | , "127.0.0.1:1338" 60 | ] 61 | , // options 62 | { maxPending: 1000 // maximum number of outstanding request to allow 63 | , maxSockets: 200 // max sockets per endpoint Agent 64 | , timeout: 60000 // request timeout in ms 65 | , resolution: 1000 // timeout check interval (see below) 66 | , keepAlive: false // use an alternate Agent that does http keep-alive properly 67 | , ping: undefined // health check url 68 | , pingTimeout: 2000 // ping timeout in ms 69 | , retryFilter: undefined // see below 70 | , retryDelay: 20 // see below 71 | , maxRetries: 5 // see below 72 | , name: undefined // optional string 73 | , agentOptions: undefined// an object for passing options directly to the Http Agent 74 | } 75 | ) 76 | ``` 77 | 78 | ###### maxPending 79 | 80 | Once this threshold is reached, requests will return an error to the callback as a 81 | signal to slow down the rate of requests. 82 | 83 | ###### resolution 84 | 85 | Pending requests have their timeouts checked at this rate. If your timeout is 60000 86 | and resolution is 1000, the request will timeout no later than 60999 87 | 88 | ###### keepAlive 89 | 90 | The default http Agent does keep-alive in a stupid way. If you want it to work 91 | how you'd expect it to set this to true. 92 | 93 | ###### retryFilter 94 | 95 | All valid http responses aren't necessarily a "success". This function lets you 96 | check the response before calling the request callback. Returning a "truthy" value 97 | will retry the request. 98 | 99 | For instance, we may want to always retry 500 responses by default: 100 | ```javascript 101 | options.retryFilter = function ( 102 | options // the request.options 103 | , response // the http response object 104 | , body // the response body 105 | ) { 106 | return response.statusCode === 500 107 | } 108 | ``` 109 | 110 | If the returned value is `true` the next attempt will be delayed using exponential backoff; 111 | if its `Number` it will delay the next attempt by that many ms (useful for `Retry-After` headers) 112 | 113 | ###### retryDelay 114 | 115 | Pool uses exponential backoff when retrying requests. This value is a scaling factor of the 116 | time (ms) to wait. Here's how it works: 117 | ```javascript 118 | Math.random() * Math.pow(2, attemptNumber) * retryDelay 119 | ``` 120 | If `retryDelay` is 20, attemptNumber 1 (the first retry) will delay at most 40ms 121 | 122 | ###### maxRetries 123 | 124 | The maximum number of attempts to make after the first request fails. This only 125 | takes effect if maxRetries < pool size. 126 | 127 | ###### agentOptions 128 | 129 | These options are passed directly to the underlying Agents used in the pool. This 130 | is nice for passing options like `cert` and `key` that are required for client certificates. 131 | 132 | ###### ping 133 | 134 | When an endpoint is unresponsive the pool will not use it for requests. The ping 135 | url gives a downed endpoint a way to rejoin the pool. If an endpoint is marked unhealthy 136 | and a ping url is given, the endpoint will make requests to its ping url until it gets 137 | a 200 response, based on the `resolution` time. 138 | 139 | If the ping url is undefined, the endpoint will never be marked unhealthy. 140 | 141 | 142 | ### pool.request 143 | 144 | An http request. The pool sends the request to one of it's endpoints. If it 145 | fails, the pool may retry the request on other endpoints until it succeeds or 146 | reaches `options.attempts` number of tries. *When `data` is a Stream, only 1 147 | attempt will be made* 148 | 149 | ###### Usage 150 | 151 | 152 | The first argument may be a url path. 153 | If the callback has 3 arguments the full response body will be returned 154 | 155 | ```javascript 156 | pool.request('/users/me', function (error, response, body) {}) 157 | ``` 158 | 159 | The first argument may be an options object. 160 | Here's the default values: 161 | 162 | ```javascript 163 | pool.request( 164 | { path: undefined // the request path (required) 165 | , method: 'GET' 166 | , data: undefined // request body, may be a string, buffer, or stream 167 | , headers: {} // extra http headers to send 168 | , retryFilter: undefined // see below 169 | , attempts: pool.length // or at least 2, at most options.maxRetries + 1 170 | , retryDelay: 20 // retries wait with exponential backoff times this number of ms 171 | , timeout: 60000 // ms to wait before timing out the request 172 | , encoding: 'utf8' // response body encoding 173 | , stream: false // stream instead of buffer response body 174 | } 175 | , 176 | function (error, response, body) {} 177 | ) 178 | ``` 179 | 180 | The request body may be the second argument, instead of options.data (more 181 | useful with `pool.post` and `pool.put`) 182 | 183 | ```javascript 184 | pool.request( 185 | { path: '/foo' } 186 | , 'hi there' 187 | , function (error, response, body) {} 188 | ) 189 | ``` 190 | 191 | A callback with 2 arguments will stream the response and not buffer the 192 | response body. 193 | 194 | ```javascript 195 | pool.request('/foo', function (error, response) { 196 | response.pipe(somewhere) 197 | }) 198 | ``` 199 | 200 | ### pool.get 201 | 202 | Just a synonym for `request` 203 | 204 | ### pool.put 205 | 206 | Same arguments as `request` that sets `options.method = 'PUT'`. Nice for 207 | putting :) 208 | 209 | ```javascript 210 | pool.put('/tweet/me', 'Hello World!', function (error, response) {}) 211 | ``` 212 | 213 | ### pool.post 214 | 215 | Same arguments as `request` that sets `options.method = 'POST'` 216 | 217 | ### pool.del 218 | 219 | Same arguments as `request` that sets `options.method = 'DELETE'` 220 | 221 | 222 | ### Events 223 | 224 | ##### timing 225 | 226 | Emits the request `duration` and `options` after each request 227 | 228 | ##### retrying 229 | 230 | Emits the `error` of why a request is being retried 231 | 232 | ##### timeout 233 | 234 | Emits the `request` when a request times out 235 | 236 | ##### health 237 | 238 | Emits the `endpoint` when a node changes state between healthy/unhealthy 239 | -------------------------------------------------------------------------------- /test/pool_test.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert") 2 | var EventEmitter = require("events").EventEmitter 3 | var inherits = require('util').inherits 4 | 5 | var noop = function () {} 6 | 7 | var http = { 8 | request: noop, 9 | Agent: noop 10 | } 11 | 12 | function FakeEndpoint() {} 13 | inherits(FakeEndpoint, EventEmitter) 14 | FakeEndpoint.prototype.pending = 1 15 | FakeEndpoint.prototype.busyness = function () { return 1 } 16 | FakeEndpoint.prototype.connected = function () { return 0 } 17 | FakeEndpoint.prototype.ready = function () { return false } 18 | var overloaded = new FakeEndpoint() 19 | FakeEndpoint.overloaded = function () { return overloaded } 20 | var unhealthy = new FakeEndpoint() 21 | FakeEndpoint.unhealthy = function () { return unhealthy } 22 | 23 | function FakeRequestSet() {} 24 | FakeRequestSet.request = function () {} 25 | 26 | function succeeding_request(pool, options, cb) { 27 | return cb(null, { socket: { _requestCount: 2 }}, "foo") 28 | } 29 | 30 | function succeeding_request_not_reused(pool, options, cb) { 31 | return cb(null, { socket: {}}, "foo") 32 | } 33 | 34 | function failing_request(pool, options, cb) { 35 | return cb({ 36 | message: "crap", 37 | reason: "ihateyou" 38 | }) 39 | } 40 | 41 | var Pool = require("../lib/pool")(inherits, EventEmitter, FakeEndpoint, FakeRequestSet) 42 | 43 | describe('Pool', function () { 44 | var pool 45 | 46 | beforeEach(function () { 47 | pool = new Pool(http, ['127.0.0.1:8080', '127.0.0.1:8081', '127.0.0.1:8082']) 48 | }) 49 | 50 | it("throws an Error if constructed with no nodes", function () { 51 | assert.throws( 52 | function () { 53 | var p = new Pool() 54 | } 55 | ) 56 | }) 57 | 58 | it("throws an Error when the node list is invalid", function () { 59 | assert.throws( 60 | function () { 61 | var p = new Pool(http, ["foo_bar"]) 62 | } 63 | ) 64 | }) 65 | 66 | it("throws an Error when http is invalid", function () { 67 | assert.throws( 68 | function () { 69 | var p = new Pool({}, ["127.0.0.1:8080"]) 70 | } 71 | ) 72 | }) 73 | 74 | it("sets this.length to this.nodes.length", function () { 75 | var p = new Pool(http, ['127.0.0.1:8080', '127.0.0.1:8081', '127.0.0.1:8082']) 76 | assert.equal(p.length, 3) 77 | }) 78 | 79 | // 80 | // healthy_nodes 81 | // 82 | ////////////////////////////////////////////////////////////////////////////// 83 | 84 | describe("healthy_nodes()", function () { 85 | 86 | it("filters out unhealthy nodes from the result", function () { 87 | pool.nodes[0].healthy = false 88 | assert.equal(true, pool.healthy_nodes().every(function (n) { 89 | return n.healthy 90 | })) 91 | }) 92 | }) 93 | 94 | // 95 | // get_node 96 | // 97 | ////////////////////////////////////////////////////////////////////////////// 98 | 99 | describe("get_node()", function () { 100 | 101 | it("returns the 'overloaded' endpoint when totalPending > maxPending", function () { 102 | var p = new Pool(http, ['127.0.0.1:8080', '127.0.0.1:8081', '127.0.0.1:8082'], { maxPending: 30 }) 103 | p.nodes.forEach(function (n) { n.pending = 10 }) 104 | assert.equal(p.get_node(), overloaded) 105 | }) 106 | 107 | it("returns the 'unhealthy' endpoint when no nodes are healthy", function () { 108 | var p = new Pool(http, ['127.0.0.1:8080', '127.0.0.1:8081', '127.0.0.1:8082']) 109 | p.nodes.forEach(function (n) { n.healthy = false }) 110 | assert.equal(p.get_node(), unhealthy) 111 | }) 112 | 113 | it('returns a "ready" node when one is available', function () { 114 | var p = new Pool(http, ['127.0.0.1:8080', '127.0.0.1:8081', '127.0.0.1:8082']) 115 | var n = p.nodes[0] 116 | n.ready = function () { return true } 117 | assert.equal(p.get_node(), n); 118 | }) 119 | 120 | it('returns a healthy node when none are "ready"', function () { 121 | var p = new Pool(http, ['127.0.0.1:8080', '127.0.0.1:8081', '127.0.0.1:8082']) 122 | p.nodes[0].healthy = false 123 | p.nodes[1].healthy = false 124 | p.nodes[2].healthy = true 125 | assert(p.get_node().healthy); 126 | }) 127 | }) 128 | 129 | // 130 | // request 131 | // 132 | ////////////////////////////////////////////////////////////////////////////// 133 | 134 | describe("request()", function () { 135 | 136 | it("calls callback with response on success", function (done) { 137 | FakeRequestSet.request = succeeding_request 138 | pool.request({}, null, function (e, r, b) { 139 | assert.equal(b, "foo") 140 | done() 141 | }) 142 | }) 143 | 144 | it("calls callback with error on failure", function (done) { 145 | FakeRequestSet.request = failing_request 146 | pool.request({}, null, function (e, r, b) { 147 | assert(e.message, "crap") 148 | done() 149 | }) 150 | }) 151 | 152 | it("emits timing on success", function (done) { 153 | FakeRequestSet.request = succeeding_request 154 | pool.on('timing', function () { 155 | done() 156 | }) 157 | 158 | pool.request({}, null, noop) 159 | }) 160 | 161 | it("emits timing on failure", function (done) { 162 | FakeRequestSet.request = failing_request 163 | pool.on('timing', function () { 164 | done() 165 | }) 166 | 167 | pool.request({}, null, noop) 168 | }) 169 | 170 | it("sets the reused field of options to true when the socket is reused", function (done) { 171 | FakeRequestSet.request = succeeding_request 172 | pool.on('timing', function (interval, options) { 173 | assert(options.reused) 174 | done() 175 | }) 176 | 177 | pool.request({}, null, noop) 178 | }) 179 | 180 | it("sets the reused field of options to false when the socket isn't reused", function (done) { 181 | FakeRequestSet.request = succeeding_request_not_reused 182 | pool.on('timing', function (interval, options) { 183 | assert(!options.reused) 184 | done() 185 | }) 186 | 187 | pool.request({}, null, noop) 188 | }) 189 | 190 | it("allows the data parameter to be optional", function (done) { 191 | FakeRequestSet.request = succeeding_request 192 | pool.request({}, function (e, r, b) { 193 | assert.equal(b, "foo") 194 | done() 195 | }) 196 | }) 197 | 198 | it("allows the options parameter to be a path string", function (done) { 199 | FakeRequestSet.request = function (pool, options, cb) { 200 | assert.equal(options.path, "/foo") 201 | return cb(null, {socket:{}}, "foo") 202 | } 203 | pool.request("/foo", function (e, r, b) { 204 | assert.equal(b, "foo") 205 | done() 206 | }) 207 | }) 208 | 209 | it("defaults method to GET", function (done) { 210 | FakeRequestSet.request = function (pool, options, cb) { 211 | assert.equal(options.method, "GET") 212 | return cb(null, {socket:{}}, "foo") 213 | } 214 | pool.request("/foo", function (e, r, b) { 215 | assert.equal(b, "foo") 216 | done() 217 | }) 218 | }) 219 | 220 | it("defaults options.stream to true when callback.length is 2", function (done) { 221 | FakeRequestSet.request = function (pool, options, cb) { 222 | assert.equal(options.stream, true) 223 | return cb(null, {socket:{}}) 224 | } 225 | pool.request("/foo", function (e, r) { 226 | done() 227 | }) 228 | }) 229 | 230 | it("defaults options.stream to false when callback.length is 3", function (done) { 231 | FakeRequestSet.request = function (pool, options, cb) { 232 | assert.equal(options.stream, false) 233 | return cb(null, {socket:{}}) 234 | } 235 | pool.request("/foo", function (e, r, b) { 236 | done() 237 | }) 238 | }) 239 | }) 240 | 241 | // 242 | // get 243 | // 244 | ////////////////////////////////////////////////////////////////////////////// 245 | 246 | describe("get()", function () { 247 | 248 | it("is an alias to request()", function () { 249 | assert.equal(pool.get, pool.request) 250 | }) 251 | }) 252 | 253 | // 254 | // put 255 | // 256 | ////////////////////////////////////////////////////////////////////////////// 257 | 258 | describe("put()", function () { 259 | 260 | it("sets the options.method to PUT", function (done) { 261 | FakeRequestSet.request = function (pool, options, cb) { 262 | assert.equal(options.method, "PUT") 263 | return cb(null, {socket:{}}, "foo") 264 | } 265 | pool.put("/foo", "bar", function (e, r, b) { 266 | assert.equal(b, "foo") 267 | done() 268 | }) 269 | }) 270 | }) 271 | 272 | // 273 | // post 274 | // 275 | ////////////////////////////////////////////////////////////////////////////// 276 | 277 | describe("post()", function () { 278 | 279 | it("sets the options.method to POST", function (done) { 280 | FakeRequestSet.request = function (pool, options, cb) { 281 | assert.equal(options.method, "POST") 282 | return cb(null, {socket:{}}, "foo") 283 | } 284 | pool.post("/foo", "bar", function (e, r, b) { 285 | assert.equal(b, "foo") 286 | done() 287 | }) 288 | }) 289 | }) 290 | 291 | // 292 | // del 293 | // 294 | ////////////////////////////////////////////////////////////////////////////// 295 | 296 | describe("del()", function () { 297 | 298 | it("sets the options.method to DELETE", function (done) { 299 | FakeRequestSet.request = function (pool, options, cb) { 300 | assert.equal(options.method, "DELETE") 301 | return cb(null, {socket:{}}, "foo") 302 | } 303 | pool.del("/foo", function (e, r, b) { 304 | assert.equal(b, "foo") 305 | done() 306 | }) 307 | }) 308 | }) 309 | }) 310 | -------------------------------------------------------------------------------- /lib/endpoint.js: -------------------------------------------------------------------------------- 1 | var Stream = require('stream') 2 | var http = require('http') 3 | var KeepAlive = require('keep-alive-agent') 4 | 5 | module.exports = function (inherits, EventEmitter, Pinger) { 6 | var MAX_COUNT = Math.pow(2, 52) 7 | var clock = Date.now() 8 | var clockInterval = null 9 | function noop() { return false } 10 | 11 | // 12 | // http: either require('http') or require('https') 13 | // ip: host ip 14 | // port: host port 15 | // options: { 16 | // ping: ping path (no ping checks) 17 | // pingTimeout: in ms (2000) 18 | // maxSockets: max concurrent open sockets (20) 19 | // timeout: default request timeout in ms (60000) 20 | // resolution: how often timeouts are checked in ms (1000) 21 | // keepAlive: use an alternate Agent that does keep-alive properly (boolean) default false 22 | // agentOptions: {} an object for passing options directly to the Http Agent 23 | // } 24 | function Endpoint(protocol, ip, port, options) { 25 | options = options || {} 26 | 27 | this.http = protocol 28 | this.ip = ip 29 | this.port = port 30 | this.healthy = true 31 | this.name = this.ip + ':' + this.port 32 | this.address = this.ip 33 | this.keepAlive = options.keepAlive 34 | 35 | this.pinger = new Pinger(this.ping.bind(this)) 36 | this.pinger.on('pong', function () { 37 | this.setHealthy(true) 38 | }.bind(this)) 39 | 40 | this.pingPath = options.ping 41 | this.pingTimeout = options.pingTimeout || 2000 42 | if (this.keepAlive) { 43 | if (protocol === http) { 44 | this.agent = new KeepAlive(options.agentOptions) 45 | } 46 | else { 47 | this.agent = new KeepAlive.Secure(options.agentOptions) 48 | } 49 | } 50 | else { 51 | this.agent = new protocol.Agent(options.agentOptions) 52 | } 53 | this.agent.maxSockets = options.maxSockets || 20 54 | 55 | this.requests = {} 56 | this.requestCount = 0 57 | this.requestsLastCheck = 0 58 | this.requestRate = 0 59 | this.pending = 0 60 | this.successes = 0 61 | this.failures = 0 62 | this.filtered = 0 63 | 64 | this.timeout = (options.timeout === 0) ? 0 : options.timeout || (60 * 1000) 65 | this.resolution = (options.resolution === 0) ? 0 : options.resolution || 1000 66 | if (this.resolution > 0 && this.timeout > 0) { 67 | this.timeoutInterval = setInterval(this.checkTimeouts.bind(this), this.resolution) 68 | } 69 | 70 | if (!clockInterval) { 71 | clockInterval = setInterval(function () { clock = Date.now() }, 10) 72 | } 73 | } 74 | inherits(Endpoint, EventEmitter) 75 | 76 | Endpoint.prototype.connected = function () { 77 | return this.agent.sockets[this.name] && this.agent.sockets[this.name].length 78 | } 79 | 80 | Endpoint.prototype.ready = function () { 81 | return this.healthy 82 | && (this.keepAlive ? 83 | this.connected() > this.pending : 84 | this.pending === 0 85 | ) 86 | } 87 | 88 | Endpoint.prototype.stats = function () { 89 | var socketNames = Object.keys(this.agent.sockets) 90 | var requestCounts = [] 91 | for (var i = 0; i < socketNames.length; i++) { 92 | var name = socketNames[i] 93 | var s = this.agent.sockets[name] || [] 94 | for (var j = 0; j < s.length; j++) { 95 | requestCounts.push(s[j]._requestCount || 1) 96 | } 97 | } 98 | return { 99 | name: this.name, 100 | requestCount: this.requestCount, 101 | requestRate: this.requestRate, 102 | pending: this.pending, 103 | successes: this.successes, 104 | failures: this.failures, 105 | filtered: this.filtered, 106 | healthy: this.healthy, 107 | socketRequestCounts: requestCounts 108 | } 109 | } 110 | 111 | Endpoint.prototype.checkTimeouts = function () { 112 | var keys = Object.keys(this.requests) 113 | for (var i = 0; i < keys.length; i++) { 114 | var r = this.requests[keys[i]] 115 | var expireTime = clock - r.options.timeout 116 | if (r.lastTouched <= expireTime) { 117 | if (r.options.path !== this.pingPath) { 118 | this.emit("timeout", r) 119 | } 120 | r.timedOut = true 121 | r.abort() 122 | } 123 | } 124 | this.requestRate = this.requestCount - this.requestsLastCheck 125 | this.requestsLastCheck = this.requestCount 126 | } 127 | 128 | Endpoint.prototype.resetCounters = function () { 129 | this.requestsLastCheck = this.requestRate - this.pending 130 | this.requestCount = this.pending 131 | this.successes = 0 132 | this.failures = 0 133 | this.filtered = 0 134 | } 135 | 136 | Endpoint.prototype.setPending = function () { 137 | this.pending = this.requestCount - (this.successes + this.failures + this.filtered) 138 | if (this.requestCount === MAX_COUNT) { 139 | this.resetCounters() 140 | } 141 | } 142 | 143 | Endpoint.prototype.complete = function (error, request, response, body) { 144 | this.deleteRequest(request.id) 145 | this.setPending() 146 | request.callback(error, response, body) 147 | request.callback = null 148 | } 149 | 150 | Endpoint.prototype.succeeded = function (request, response, body) { 151 | this.successes++ 152 | this.complete(null, request, response, body) 153 | } 154 | 155 | Endpoint.prototype.failed = function (error, request) { 156 | this.failures++ 157 | this.setHealthy(false) 158 | this.complete(error, request) 159 | } 160 | 161 | Endpoint.prototype.filterRejected = function (error, request) { 162 | this.filtered++ 163 | this.complete(error, request) 164 | } 165 | 166 | Endpoint.prototype.busyness = function () { 167 | return this.pending 168 | } 169 | 170 | // options: { 171 | // agent: 172 | // path: 173 | // method: 174 | // retryFilter: 175 | // timeout: request timeout in ms (this.timeout) 176 | // encoding: response body encoding (utf8) 177 | // data: string, buffer, or stream 178 | // stream: stream instead of buffer response body (default based on callback) 179 | // } 180 | // callback: function (error, response, body) {} 181 | // callback: function (error, response) {} 182 | Endpoint.prototype.request = function (options, callback) { 183 | options.host = this.ip 184 | options.port = this.port 185 | options.retryFilter = options.retryFilter || noop 186 | options.timeout = options.timeout || this.timeout 187 | options.headers = options.headers || {} 188 | if (options.agent !== false) { 189 | options.agent = this.agent 190 | } 191 | if (options.encoding !== null) { 192 | options.encoding = options.encoding || 'utf8' 193 | } 194 | 195 | var req = this.http.request(options) 196 | req.node = this 197 | req.options = options 198 | req.id = this.requestCount++ 199 | req.callback = callback || noop 200 | req.stream = (options.stream === undefined) ? req.callback.length === 2 : options.stream 201 | req.lastTouched = clock 202 | req.on('response', gotResponse) 203 | req.on('error', gotError) 204 | 205 | var data = options.data 206 | if (data instanceof Stream) { 207 | data.pipe(req) 208 | } 209 | else { 210 | if (data) { 211 | req.setHeader("Content-Length" 212 | , Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data) 213 | ) 214 | } 215 | req.end(data) 216 | } 217 | 218 | this.setPending() 219 | this.requests[req.id] = req 220 | } 221 | 222 | Endpoint.prototype.setHealthy = function (newState) { 223 | if (!this.pingPath) { 224 | return // an endpoint with no pingPath can never be made unhealthy 225 | } 226 | if (!newState) { 227 | this.pinger.start() 228 | } 229 | if (this.healthy !== newState) { 230 | this.healthy = newState 231 | this.emit('health', this) 232 | } 233 | } 234 | 235 | Endpoint.prototype.deleteRequest = function (id) { 236 | delete this.requests[id] 237 | } 238 | 239 | Endpoint.prototype.ping = function (cb) { 240 | return this.request( 241 | { path: this.pingPath 242 | , method: 'GET' 243 | , timeout: this.pingTimeout 244 | } 245 | , cb 246 | ) 247 | } 248 | 249 | // this = request 250 | function gotResponse(response) { 251 | if (this.stream) { 252 | return this.node.succeeded(this, response) 253 | } 254 | response.bodyChunks = [] 255 | response.bodyLength = 0 256 | response.request = this 257 | response.on('data', gotData) 258 | response.on('end', gotEnd) 259 | response.on('aborted', gotAborted) 260 | } 261 | 262 | // this = request 263 | function gotError(error) { 264 | var msg = this.node.ip + ':' + this.node.port + ' error: ' 265 | msg += this.timedOut ? 'request timed out' : error.message 266 | this.node.failed( 267 | { reason: error.message 268 | , attempt: this 269 | , message: msg 270 | } 271 | , this) 272 | } 273 | 274 | // this = response 275 | function gotData(chunk) { 276 | this.request.lastTouched = clock 277 | this.bodyChunks.push(chunk) 278 | this.bodyLength += chunk.length 279 | } 280 | 281 | // this = response 282 | function gotEnd() { 283 | var req = this.request 284 | var opt = req.options 285 | var node = req.node 286 | 287 | if (req.callback === null) { return } 288 | 289 | var buffer = new Buffer(this.bodyLength) 290 | var offset = 0 291 | for (var i = 0; i < this.bodyChunks.length; i++) { 292 | var chunk = this.bodyChunks[i] 293 | chunk.copy(buffer, offset, 0, chunk.length) 294 | offset += chunk.length 295 | } 296 | 297 | var body = (opt.encoding !== null) ? buffer.toString(opt.encoding) : buffer 298 | 299 | var delay = opt.retryFilter(opt, this, body) 300 | if (delay !== false) { // delay may be 0 301 | return node.filterRejected( 302 | { delay: delay 303 | , reason: 'filter' 304 | , attempt: req 305 | , message: node.ip + ':' + node.port + ' error: rejected by filter' 306 | } 307 | , req) 308 | } 309 | node.succeeded(req, this, body) 310 | } 311 | 312 | // this = response 313 | function gotAborted() { 314 | var msg = this.request.node.ip + ':' + this.request.node.port + ' error: ' 315 | msg += this.request.timedOut ? 'response timed out' : 'connection aborted' 316 | this.request.node.failed( 317 | { reason: 'aborted' 318 | , attempt: this.request 319 | , message: msg 320 | } 321 | , this.request) 322 | } 323 | 324 | var overloaded = null 325 | 326 | Endpoint.overloaded = function () { 327 | if (!overloaded) { 328 | overloaded = new Endpoint({Agent: Object}, null, null, {timeout: 0}) 329 | overloaded.healthy = false 330 | overloaded.request = function (options, callback) { 331 | return callback( 332 | { reason: 'full' 333 | , delay: true 334 | , attempt: { options: options } 335 | , message: 'too many pending requests' 336 | } 337 | ) 338 | } 339 | } 340 | return overloaded 341 | } 342 | 343 | var unhealthy = null 344 | 345 | Endpoint.unhealthy = function () { 346 | if (!unhealthy) { 347 | unhealthy = new Endpoint({Agent: Object}, null, null, {timeout: 0}) 348 | unhealthy.healthy = false 349 | unhealthy.request = function (options, callback) { 350 | return callback( 351 | { reason: 'unhealthy' 352 | , delay: true 353 | , attempt: { options: options } 354 | , message: 'no nodes' 355 | } 356 | ) 357 | } 358 | } 359 | return unhealthy 360 | } 361 | 362 | return Endpoint 363 | } 364 | -------------------------------------------------------------------------------- /test/endpoint_test.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert") 2 | var EventEmitter = require("events").EventEmitter 3 | var inherits = require('util').inherits 4 | var http = require('http') 5 | var https = require('https') 6 | var Stream = require('stream') 7 | 8 | var noop = function () {} 9 | 10 | var Pinger = require('../lib/pinger')(inherits, EventEmitter) 11 | var Endpoint = require("../lib/endpoint")(inherits, EventEmitter, Pinger) 12 | 13 | describe("Endpoint", function () { 14 | 15 | it("passes nothing to the Agent constructor when no agentOptions are given", function () { 16 | var e = new Endpoint(http, '127.0.0.1', 6969, { bogus: true }) 17 | assert.equal(e.agent.options.bogus, undefined) 18 | }) 19 | 20 | it("passes agentOptions to the underlying Agent (no keep-alive)", function () { 21 | var e = new Endpoint(http, '127.0.0.1', 6969, { agentOptions: { cert: 'foo', key: 'bar'}}) 22 | assert.equal(e.agent.options.cert, 'foo') 23 | assert.equal(e.agent.options.key, 'bar') 24 | }) 25 | 26 | it("passes agentOptions to the underlying Agent (keep-alive)", function () { 27 | var e = new Endpoint(http, '127.0.0.1', 6969, {keepAlive: true, agentOptions: { cert: 'foo', key: 'bar'}}) 28 | assert.equal(e.agent.options.cert, 'foo') 29 | assert.equal(e.agent.options.key, 'bar') 30 | }) 31 | 32 | it("passes agentOptions to the underlying Agent (keep-alive secure)", function () { 33 | var e = new Endpoint(https, '127.0.0.1', 6969, {keepAlive: true, agentOptions: { cert: 'foo', key: 'bar'}}) 34 | assert.equal(e.agent.options.cert, 'foo') 35 | assert.equal(e.agent.options.key, 'bar') 36 | }) 37 | 38 | // 39 | // unhealthy 40 | // 41 | ////////////////////////////////////////////////////////////////////////////// 42 | 43 | describe("unhealthy", function () { 44 | 45 | it("returns a 'unhealthy' error on request", function () { 46 | Endpoint.unhealthy().request({}, function (err) { 47 | assert.equal(err.reason, "unhealthy") 48 | }) 49 | }) 50 | 51 | it("is not healthy", function () { 52 | assert.equal(false, Endpoint.unhealthy().healthy) 53 | }) 54 | }) 55 | 56 | // 57 | // overloaded 58 | // 59 | ////////////////////////////////////////////////////////////////////////////// 60 | 61 | describe("overloaded", function () { 62 | it("returns a 'full' error on request", function () { 63 | Endpoint.overloaded().request({}, function (err) { 64 | assert.equal(err.reason, "full") 65 | }) 66 | }) 67 | 68 | it("is not healthy", function () { 69 | assert.equal(false, Endpoint.overloaded().healthy) 70 | }) 71 | }) 72 | 73 | // 74 | // request 75 | // 76 | ////////////////////////////////////////////////////////////////////////////// 77 | 78 | describe("request()", function () { 79 | 80 | it("sends Content-Length when data is a string", function (done) { 81 | var s = http.createServer(function (req, res) { 82 | assert.equal(req.headers["content-length"], 4) 83 | res.end("foo") 84 | s.close() 85 | done() 86 | }) 87 | s.on('listening', function () { 88 | var e = new Endpoint(http, '127.0.0.1', 6969) 89 | e.request({path:'/foo', method: 'PUT', data: "ƒoo"}, noop) 90 | }) 91 | s.listen(6969) 92 | }) 93 | 94 | it("sends Content-Length when data is a buffer", function (done) { 95 | var s = http.createServer(function (req, res) { 96 | assert.equal(req.headers["content-length"], 4) 97 | res.end("foo") 98 | s.close() 99 | done() 100 | }) 101 | s.on('listening', function () { 102 | var e = new Endpoint(http, '127.0.0.1', 6969) 103 | e.request({path:'/foo', method: 'PUT', data: Buffer("ƒoo")}, noop) 104 | }) 105 | s.listen(6969) 106 | }) 107 | 108 | it("pipes data to the request when it is a Stream", function (done) { 109 | var put = "ƒoo" 110 | var putStream = new Stream() 111 | var s = http.createServer(function (req, res) { 112 | var d = '' 113 | req.on('data', function (data) { d += data }) 114 | req.on('end', function () { 115 | assert.equal(d, put) 116 | s.close() 117 | done() 118 | }) 119 | }) 120 | 121 | s.on('listening', function () { 122 | var e = new Endpoint(http, '127.0.0.1', 6969) 123 | e.request({path:'/foo', method: 'PUT', data: putStream}, noop) 124 | putStream.emit('data','ƒ') 125 | putStream.emit('data','o') 126 | putStream.emit('data','o') 127 | putStream.emit('end') 128 | }) 129 | s.listen(6969) 130 | }) 131 | 132 | it("times out and returns an error when the server fails to respond in time", function (done) { 133 | var s = http.createServer(function (req, res) { 134 | setTimeout(function () { 135 | res.end("foo") 136 | }, 30) 137 | }) 138 | s.on('listening', function () { 139 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 20, resolution: 10}) 140 | var error 141 | e.request({path:'/foo', method: 'GET'}, function (err, response, body) { 142 | error = err 143 | }) 144 | setTimeout(function () { 145 | s.close() 146 | assert.equal(error.reason, "socket hang up") 147 | assert.equal(/request timed out$/.test(error.message), true) 148 | done() 149 | }, 40) 150 | }) 151 | s.listen(6969) 152 | }) 153 | 154 | it("times out and returns an error when the server response hasn't sent any data within the timeout", function (done) { 155 | this.timeout(0) 156 | var s = http.createServer(function (req, res) { 157 | res.writeHead(200) 158 | 159 | setTimeout(function () { 160 | res.write('foo') 161 | }, 10) 162 | 163 | setTimeout(function () { 164 | res.write('bar') 165 | }, 40) 166 | 167 | }) 168 | s.on('listening', function () { 169 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 15, resolution: 10}) 170 | var error 171 | e.request({path:'/foo', method: 'GET'}, function (err, response, body) { 172 | error = err 173 | }) 174 | 175 | setTimeout(function () { 176 | s.close() 177 | assert.equal(error.reason, "aborted") 178 | assert.equal(/response timed out$/.test(error.message), true) 179 | done() 180 | }, 60) 181 | }) 182 | s.listen(6969) 183 | }) 184 | 185 | it("emits a timeout event on timeout", function (done) { 186 | var s = http.createServer(function (req, res) { 187 | setTimeout(function () { 188 | res.end("foo") 189 | }, 30) 190 | }) 191 | s.on('listening', function () { 192 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 20, resolution: 10}) 193 | var fin = false 194 | e.on('timeout', function () { 195 | fin = true 196 | }) 197 | e.request({path:'/foo', method: 'GET'}, noop) 198 | 199 | setTimeout(function () { 200 | s.close() 201 | assert.equal(fin, true) 202 | done() 203 | }, 60) 204 | }) 205 | s.listen(6969) 206 | }) 207 | 208 | it("removes the request from this.requests on timeout", function (done) { 209 | var s = http.createServer(function (req, res) { 210 | setTimeout(function () { 211 | res.end("foo") 212 | }, 30) 213 | }) 214 | s.on('listening', function () { 215 | var e = new Endpoint(http, '127.0.0.1', 6969, {keepAlive: true, timeout: 20, resolution: 10}) 216 | var fin = false 217 | e.on('timeout', function () { 218 | fin = true 219 | }) 220 | e.request({path:'/foo', method: 'GET'}, noop) 221 | e.request({path:'/foo', method: 'GET'}, noop) 222 | e.request({path:'/foo', method: 'GET'}, noop) 223 | 224 | setTimeout(function () { 225 | assert.equal(fin, true) 226 | assert.equal(Object.keys(e.requests).length, 0) 227 | s.close() 228 | done() 229 | }, 100) 230 | }) 231 | s.listen(6969) 232 | }) 233 | 234 | it("removes the request from this.requests on error", function (done) { 235 | var s = http.createServer(function (req, res) { 236 | setTimeout(function () { 237 | res.end("foo") 238 | }, 30) 239 | }) 240 | s.on('listening', function () { 241 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 20, resolution: 10}) 242 | var error 243 | e.request({path:'/foo', method: 'GET'}, function (err, response, body) { 244 | error = err 245 | }) 246 | 247 | setTimeout(function () { 248 | s.close() 249 | assert.equal(error.reason, "socket hang up") 250 | assert.equal(Object.keys(e.requests).length, 0) 251 | done() 252 | }, 50) 253 | }) 254 | s.listen(6969) 255 | }) 256 | 257 | it("removes the request from this.requests on aborted", function (done) { 258 | var s = http.createServer(function (req, res) { 259 | res.writeHead(200) 260 | res.write('foo') 261 | setTimeout(function () { 262 | req.connection.destroy() 263 | }, 10) 264 | }) 265 | s.on('listening', function () { 266 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 20, resolution: 10}) 267 | var error 268 | e.request({path:'/foo', method: 'GET'}, function (err, response, body) { 269 | error = err 270 | }) 271 | 272 | setTimeout(function () { 273 | s.close() 274 | assert.equal(error.reason, "aborted") 275 | assert.equal(Object.keys(e.requests).length, 0) 276 | done() 277 | }, 50) 278 | }) 279 | s.listen(6969) 280 | }) 281 | 282 | it("removes the request from this.requests on success", function (done) { 283 | var s = http.createServer(function (req, res) { 284 | setTimeout(function () { 285 | res.end("foo") 286 | }, 10) 287 | }) 288 | s.on('listening', function () { 289 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 20, resolution: 10}) 290 | var error 291 | e.request({path:'/foo', method: 'GET'}, function (err, response, body) { 292 | error = err 293 | }) 294 | 295 | setTimeout(function () { 296 | s.close() 297 | assert.equal(error, null) 298 | assert.equal(Object.keys(e.requests).length, 0) 299 | done() 300 | }, 50) 301 | }) 302 | s.listen(6969) 303 | }) 304 | 305 | it("returns the whole body to the callback", function (done) { 306 | var s = http.createServer(function (req, res) { 307 | res.write("foo") 308 | setTimeout(function () { 309 | res.end("bar") 310 | }, 10) 311 | }) 312 | s.on('listening', function () { 313 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 20, resolution: 10}) 314 | var body 315 | e.request({path:'/foo', method: 'GET'}, function (err, response, b) { 316 | body = b 317 | }) 318 | 319 | setTimeout(function () { 320 | s.close() 321 | assert.equal(body, "foobar") 322 | done() 323 | }, 50) 324 | }) 325 | s.listen(6969) 326 | }) 327 | 328 | it("buffers the response when callback has 3 arguments and options.stream is not true", function (done) { 329 | var s = http.createServer(function (req, res) { 330 | res.end("foo") 331 | }) 332 | s.on('listening', function () { 333 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 20, resolution: 10, maxPending: 1}) 334 | e.request({path:'/ping', method: 'GET'}, function (err, response, body) { 335 | assert.equal(response.statusCode, 200) 336 | assert.equal(response.complete, true) 337 | s.close() 338 | done() 339 | }) 340 | }) 341 | s.listen(6969) 342 | }) 343 | 344 | it("streams the response when callback has 2 arguments", function (done) { 345 | var s = http.createServer(function (req, res) { 346 | res.end("foo") 347 | }) 348 | s.on('listening', function () { 349 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 20, resolution: 10, maxPending: 1}) 350 | e.request({path:'/ping', method: 'GET'}, function (err, response) { 351 | assert.equal(response.statusCode, 200) 352 | assert.equal(response.complete, false) 353 | s.close() 354 | done() 355 | }) 356 | }) 357 | s.listen(6969) 358 | }) 359 | 360 | it("streams the response when options.stream is true", function (done) { 361 | var s = http.createServer(function (req, res) { 362 | res.end("foo") 363 | }) 364 | s.on('listening', function () { 365 | var e = new Endpoint(http, '127.0.0.1', 6969, {timeout: 20, resolution: 10, maxPending: 1}) 366 | e.request({path:'/ping', method: 'GET', stream: true}, function (err, response, body) { 367 | assert.equal(response.statusCode, 200) 368 | assert.equal(response.complete, false) 369 | assert.equal(body, undefined) 370 | s.close() 371 | done() 372 | }) 373 | }) 374 | s.listen(6969) 375 | }) 376 | }) 377 | 378 | // 379 | // setPending 380 | // 381 | ////////////////////////////////////////////////////////////////////////////// 382 | 383 | describe("setPending()", function () { 384 | 385 | it("maintains the correct pending count when requestCount 'overflows'", function () { 386 | var e = new Endpoint(http, '127.0.0.1', 6969) 387 | e.successes = (Math.pow(2, 52) / 2) - 250 388 | e.failures = (Math.pow(2, 52) / 2) - 251 389 | e.filtered = 1 390 | e.requestCount = Math.pow(2, 52) 391 | e.setPending() 392 | assert.equal(e.pending, 500) 393 | assert.equal(e.requestCount, 500) 394 | }) 395 | 396 | it("maintains the correct requestRate when requestCount 'overflows'", function () { 397 | var e = new Endpoint(http, '127.0.0.1', 6969) 398 | e.pending = 500 399 | e.requestRate = 500 400 | e.requestCount = Math.pow(2, 52) 401 | e.requestsLastCheck = e.requestCount - 500 402 | e.resetCounters() 403 | assert.equal(e.requestCount - e.requestsLastCheck, e.requestRate) 404 | }) 405 | }) 406 | 407 | // 408 | // resetCounters 409 | // 410 | ////////////////////////////////////////////////////////////////////////////// 411 | 412 | describe("resetCounters()", function () { 413 | 414 | it("sets successes, failures and filtered to 0", function () { 415 | var e = new Endpoint(http, '127.0.0.1', 6969) 416 | e.successes = (Math.pow(2, 52) / 2) - 250 417 | e.failures = (Math.pow(2, 52) / 2) - 251 418 | e.filtered = 1 419 | e.requestCount = Math.pow(2, 52) 420 | e.resetCounters() 421 | assert.equal(e.successes, 0) 422 | assert.equal(e.failures, 0) 423 | assert.equal(e.filtered, 0) 424 | }) 425 | 426 | it("sets requestCount = pending", function () { 427 | var e = new Endpoint(http, '127.0.0.1', 6969) 428 | e.pending = 500 429 | e.requestRate = 400 430 | e.requestCount = Math.pow(2, 52) 431 | e.resetCounters() 432 | assert.equal(e.requestCount, 500) 433 | }) 434 | 435 | it("sets requestsLastCheck = requestRate - pending", function () { 436 | var e = new Endpoint(http, '127.0.0.1', 6969) 437 | e.pending = 500 438 | e.requestRate = 600 439 | e.resetCounters() 440 | assert.equal(e.requestsLastCheck, 100) 441 | }) 442 | }) 443 | 444 | // 445 | // ready 446 | // 447 | ////////////////////////////////////////////////////////////////////////////// 448 | 449 | describe("ready()", function () { 450 | 451 | it('returns true when it is healthy and connected > pending with keepAlive on', 452 | function () { 453 | var e = new Endpoint(http, '127.0.0.1', 6969, {keepAlive: true}) 454 | e.pending = 1 455 | e.agent.sockets[e.name] = [1,2] 456 | assert(e.ready()) 457 | } 458 | ) 459 | 460 | it('returns false when it is healthy and connected = pending with keepAlive on', 461 | function () { 462 | var e = new Endpoint(http, '127.0.0.1', 6969, {keepAlive: true}) 463 | e.pending = 1 464 | e.agent.sockets[e.name] = [1] 465 | assert(!e.ready()) 466 | } 467 | ) 468 | 469 | it('returns true when it is healthy and pending = 0 with keepAlive off', 470 | function () { 471 | var e = new Endpoint(http, '127.0.0.1', 6969) 472 | e.pending = 0 473 | assert(e.ready()) 474 | } 475 | ) 476 | 477 | it('returns false when it is healthy and pending > 0 with keepAlive off', 478 | function () { 479 | var e = new Endpoint(http, '127.0.0.1', 6969) 480 | e.pending = 1 481 | assert(!e.ready()) 482 | } 483 | ) 484 | }) 485 | 486 | // 487 | // setHealthy 488 | // 489 | ////////////////////////////////////////////////////////////////////////////// 490 | 491 | describe("setHealthy()", function () { 492 | 493 | it("calls pinger.start if transitioning from healthy to unhealthy", function (done) { 494 | var e = new Endpoint(http, '127.0.0.1', 6969, {ping: '/ping'}) 495 | e.pinger.start = done 496 | e.setHealthy(false) 497 | }) 498 | 499 | it("emits 'health' once when changing state from healthy to unhealthy", function (done) { 500 | var e = new Endpoint(http, '127.0.0.1', 6969, {ping: '/ping'}) 501 | e.emit = function (name) { 502 | assert.equal(name, "health") 503 | done() 504 | } 505 | e.setHealthy(false) 506 | }) 507 | 508 | it("emits 'health' once when changing state from unhealthy to healthy", function (done) { 509 | var e = new Endpoint(http, '127.0.0.1', 6969, {ping: '/ping'}) 510 | e.emit = function (name) { 511 | assert.equal(name, "health") 512 | done() 513 | } 514 | e.healthy = false 515 | e.setHealthy(true) 516 | }) 517 | }) 518 | }) 519 | --------------------------------------------------------------------------------