You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

399 lines
15 KiB
JavaScript

'use strict';
const OriginalAgent = require('http').Agent;
const ms = require('humanize-ms');
const debug = require('debug')('agentkeepalive');
const deprecate = require('depd')('agentkeepalive');
const {
INIT_SOCKET,
CURRENT_ID,
CREATE_ID,
SOCKET_CREATED_TIME,
SOCKET_NAME,
SOCKET_REQUEST_COUNT,
SOCKET_REQUEST_FINISHED_COUNT,
} = require('./constants');
// OriginalAgent come from
// - https://github.com/nodejs/node/blob/v8.12.0/lib/_http_agent.js
// - https://github.com/nodejs/node/blob/v10.12.0/lib/_http_agent.js
// node <= 10
let defaultTimeoutListenerCount = 1;
const majorVersion = parseInt(process.version.split('.', 1)[0].substring(1));
if (majorVersion >= 11 && majorVersion <= 12) {
defaultTimeoutListenerCount = 2;
} else if (majorVersion >= 13) {
defaultTimeoutListenerCount = 3;
}
class Agent extends OriginalAgent {
constructor(options) {
options = options || {};
options.keepAlive = options.keepAlive !== false;
// default is keep-alive and 4s free socket timeout
// see https://medium.com/ssense-tech/reduce-networking-errors-in-nodejs-23b4eb9f2d83
if (options.freeSocketTimeout === undefined) {
options.freeSocketTimeout = 4000;
}
// Legacy API: keepAliveTimeout should be rename to `freeSocketTimeout`
if (options.keepAliveTimeout) {
deprecate('options.keepAliveTimeout is deprecated, please use options.freeSocketTimeout instead');
options.freeSocketTimeout = options.keepAliveTimeout;
delete options.keepAliveTimeout;
}
// Legacy API: freeSocketKeepAliveTimeout should be rename to `freeSocketTimeout`
if (options.freeSocketKeepAliveTimeout) {
deprecate('options.freeSocketKeepAliveTimeout is deprecated, please use options.freeSocketTimeout instead');
options.freeSocketTimeout = options.freeSocketKeepAliveTimeout;
delete options.freeSocketKeepAliveTimeout;
}
// Sets the socket to timeout after timeout milliseconds of inactivity on the socket.
// By default is double free socket timeout.
if (options.timeout === undefined) {
// make sure socket default inactivity timeout >= 8s
options.timeout = Math.max(options.freeSocketTimeout * 2, 8000);
}
// support humanize format
options.timeout = ms(options.timeout);
options.freeSocketTimeout = ms(options.freeSocketTimeout);
options.socketActiveTTL = options.socketActiveTTL ? ms(options.socketActiveTTL) : 0;
super(options);
this[CURRENT_ID] = 0;
// create socket success counter
this.createSocketCount = 0;
this.createSocketCountLastCheck = 0;
this.createSocketErrorCount = 0;
this.createSocketErrorCountLastCheck = 0;
this.closeSocketCount = 0;
this.closeSocketCountLastCheck = 0;
// socket error event count
this.errorSocketCount = 0;
this.errorSocketCountLastCheck = 0;
// request finished counter
this.requestCount = 0;
this.requestCountLastCheck = 0;
// including free socket timeout counter
this.timeoutSocketCount = 0;
this.timeoutSocketCountLastCheck = 0;
this.on('free', socket => {
// https://github.com/nodejs/node/pull/32000
// Node.js native agent will check socket timeout eqs agent.options.timeout.
// Use the ttl or freeSocketTimeout to overwrite.
const timeout = this.calcSocketTimeout(socket);
if (timeout > 0 && socket.timeout !== timeout) {
socket.setTimeout(timeout);
}
});
}
get freeSocketKeepAliveTimeout() {
deprecate('agent.freeSocketKeepAliveTimeout is deprecated, please use agent.options.freeSocketTimeout instead');
return this.options.freeSocketTimeout;
}
get timeout() {
deprecate('agent.timeout is deprecated, please use agent.options.timeout instead');
return this.options.timeout;
}
get socketActiveTTL() {
deprecate('agent.socketActiveTTL is deprecated, please use agent.options.socketActiveTTL instead');
return this.options.socketActiveTTL;
}
calcSocketTimeout(socket) {
/**
* return <= 0: should free socket
* return > 0: should update socket timeout
* return undefined: not find custom timeout
*/
let freeSocketTimeout = this.options.freeSocketTimeout;
const socketActiveTTL = this.options.socketActiveTTL;
if (socketActiveTTL) {
// check socketActiveTTL
const aliveTime = Date.now() - socket[SOCKET_CREATED_TIME];
const diff = socketActiveTTL - aliveTime;
if (diff <= 0) {
return diff;
}
if (freeSocketTimeout && diff < freeSocketTimeout) {
freeSocketTimeout = diff;
}
}
// set freeSocketTimeout
if (freeSocketTimeout) {
// set free keepalive timer
// try to use socket custom freeSocketTimeout first, support headers['keep-alive']
// https://github.com/node-modules/urllib/blob/b76053020923f4d99a1c93cf2e16e0c5ba10bacf/lib/urllib.js#L498
const customFreeSocketTimeout = socket.freeSocketTimeout || socket.freeSocketKeepAliveTimeout;
return customFreeSocketTimeout || freeSocketTimeout;
}
}
keepSocketAlive(socket) {
const result = super.keepSocketAlive(socket);
// should not keepAlive, do nothing
if (!result) return result;
const customTimeout = this.calcSocketTimeout(socket);
if (typeof customTimeout === 'undefined') {
return true;
}
if (customTimeout <= 0) {
debug('%s(requests: %s, finished: %s) free but need to destroy by TTL, request count %s, diff is %s',
socket[SOCKET_NAME], socket[SOCKET_REQUEST_COUNT], socket[SOCKET_REQUEST_FINISHED_COUNT], customTimeout);
return false;
}
if (socket.timeout !== customTimeout) {
socket.setTimeout(customTimeout);
}
return true;
}
// only call on addRequest
reuseSocket(...args) {
// reuseSocket(socket, req)
super.reuseSocket(...args);
const socket = args[0];
const req = args[1];
req.reusedSocket = true;
const agentTimeout = this.options.timeout;
if (getSocketTimeout(socket) !== agentTimeout) {
// reset timeout before use
socket.setTimeout(agentTimeout);
debug('%s reset timeout to %sms', socket[SOCKET_NAME], agentTimeout);
}
socket[SOCKET_REQUEST_COUNT]++;
debug('%s(requests: %s, finished: %s) reuse on addRequest, timeout %sms',
socket[SOCKET_NAME], socket[SOCKET_REQUEST_COUNT], socket[SOCKET_REQUEST_FINISHED_COUNT],
getSocketTimeout(socket));
}
[CREATE_ID]() {
const id = this[CURRENT_ID]++;
if (this[CURRENT_ID] === Number.MAX_SAFE_INTEGER) this[CURRENT_ID] = 0;
return id;
}
[INIT_SOCKET](socket, options) {
// bugfix here.
// https on node 8, 10 won't set agent.options.timeout by default
// TODO: need to fix on node itself
if (options.timeout) {
const timeout = getSocketTimeout(socket);
if (!timeout) {
socket.setTimeout(options.timeout);
}
}
if (this.options.keepAlive) {
// Disable Nagle's algorithm: http://blog.caustik.com/2012/04/08/scaling-node-js-to-100k-concurrent-connections/
// https://fengmk2.com/benchmark/nagle-algorithm-delayed-ack-mock.html
socket.setNoDelay(true);
}
this.createSocketCount++;
if (this.options.socketActiveTTL) {
socket[SOCKET_CREATED_TIME] = Date.now();
}
// don't show the hole '-----BEGIN CERTIFICATE----' key string
socket[SOCKET_NAME] = `sock[${this[CREATE_ID]()}#${options._agentKey}]`.split('-----BEGIN', 1)[0];
socket[SOCKET_REQUEST_COUNT] = 1;
socket[SOCKET_REQUEST_FINISHED_COUNT] = 0;
installListeners(this, socket, options);
}
createConnection(options, oncreate) {
let called = false;
const onNewCreate = (err, socket) => {
if (called) return;
called = true;
if (err) {
this.createSocketErrorCount++;
return oncreate(err);
}
this[INIT_SOCKET](socket, options);
oncreate(err, socket);
};
const newSocket = super.createConnection(options, onNewCreate);
if (newSocket) onNewCreate(null, newSocket);
}
get statusChanged() {
const changed = this.createSocketCount !== this.createSocketCountLastCheck ||
this.createSocketErrorCount !== this.createSocketErrorCountLastCheck ||
this.closeSocketCount !== this.closeSocketCountLastCheck ||
this.errorSocketCount !== this.errorSocketCountLastCheck ||
this.timeoutSocketCount !== this.timeoutSocketCountLastCheck ||
this.requestCount !== this.requestCountLastCheck;
if (changed) {
this.createSocketCountLastCheck = this.createSocketCount;
this.createSocketErrorCountLastCheck = this.createSocketErrorCount;
this.closeSocketCountLastCheck = this.closeSocketCount;
this.errorSocketCountLastCheck = this.errorSocketCount;
this.timeoutSocketCountLastCheck = this.timeoutSocketCount;
this.requestCountLastCheck = this.requestCount;
}
return changed;
}
getCurrentStatus() {
return {
createSocketCount: this.createSocketCount,
createSocketErrorCount: this.createSocketErrorCount,
closeSocketCount: this.closeSocketCount,
errorSocketCount: this.errorSocketCount,
timeoutSocketCount: this.timeoutSocketCount,
requestCount: this.requestCount,
freeSockets: inspect(this.freeSockets),
sockets: inspect(this.sockets),
requests: inspect(this.requests),
};
}
}
// node 8 don't has timeout attribute on socket
// https://github.com/nodejs/node/pull/21204/files#diff-e6ef024c3775d787c38487a6309e491dR408
function getSocketTimeout(socket) {
return socket.timeout || socket._idleTimeout;
}
function installListeners(agent, socket, options) {
debug('%s create, timeout %sms', socket[SOCKET_NAME], getSocketTimeout(socket));
// listener socket events: close, timeout, error, free
function onFree() {
// create and socket.emit('free') logic
// https://github.com/nodejs/node/blob/master/lib/_http_agent.js#L311
// no req on the socket, it should be the new socket
if (!socket._httpMessage && socket[SOCKET_REQUEST_COUNT] === 1) return;
socket[SOCKET_REQUEST_FINISHED_COUNT]++;
agent.requestCount++;
debug('%s(requests: %s, finished: %s) free',
socket[SOCKET_NAME], socket[SOCKET_REQUEST_COUNT], socket[SOCKET_REQUEST_FINISHED_COUNT]);
// should reuse on pedding requests?
const name = agent.getName(options);
if (socket.writable && agent.requests[name] && agent.requests[name].length) {
// will be reuse on agent free listener
socket[SOCKET_REQUEST_COUNT]++;
debug('%s(requests: %s, finished: %s) will be reuse on agent free event',
socket[SOCKET_NAME], socket[SOCKET_REQUEST_COUNT], socket[SOCKET_REQUEST_FINISHED_COUNT]);
}
}
socket.on('free', onFree);
function onClose(isError) {
debug('%s(requests: %s, finished: %s) close, isError: %s',
socket[SOCKET_NAME], socket[SOCKET_REQUEST_COUNT], socket[SOCKET_REQUEST_FINISHED_COUNT], isError);
agent.closeSocketCount++;
}
socket.on('close', onClose);
// start socket timeout handler
function onTimeout() {
// onTimeout and emitRequestTimeout(_http_client.js)
// https://github.com/nodejs/node/blob/v12.x/lib/_http_client.js#L711
const listenerCount = socket.listeners('timeout').length;
// node <= 10, default listenerCount is 1, onTimeout
// 11 < node <= 12, default listenerCount is 2, onTimeout and emitRequestTimeout
// node >= 13, default listenerCount is 3, onTimeout,
// onTimeout(https://github.com/nodejs/node/pull/32000/files#diff-5f7fb0850412c6be189faeddea6c5359R333)
// and emitRequestTimeout
const timeout = getSocketTimeout(socket);
const req = socket._httpMessage;
const reqTimeoutListenerCount = req && req.listeners('timeout').length || 0;
debug('%s(requests: %s, finished: %s) timeout after %sms, listeners %s, defaultTimeoutListenerCount %s, hasHttpRequest %s, HttpRequest timeoutListenerCount %s',
socket[SOCKET_NAME], socket[SOCKET_REQUEST_COUNT], socket[SOCKET_REQUEST_FINISHED_COUNT],
timeout, listenerCount, defaultTimeoutListenerCount, !!req, reqTimeoutListenerCount);
if (debug.enabled) {
debug('timeout listeners: %s', socket.listeners('timeout').map(f => f.name).join(', '));
}
agent.timeoutSocketCount++;
const name = agent.getName(options);
if (agent.freeSockets[name] && agent.freeSockets[name].indexOf(socket) !== -1) {
// free socket timeout, destroy quietly
socket.destroy();
// Remove it from freeSockets list immediately to prevent new requests
// from being sent through this socket.
agent.removeSocket(socket, options);
debug('%s is free, destroy quietly', socket[SOCKET_NAME]);
} else {
// if there is no any request socket timeout handler,
// agent need to handle socket timeout itself.
//
// custom request socket timeout handle logic must follow these rules:
// 1. Destroy socket first
// 2. Must emit socket 'agentRemove' event tell agent remove socket
// from freeSockets list immediately.
// Otherise you may be get 'socket hang up' error when reuse
// free socket and timeout happen in the same time.
if (reqTimeoutListenerCount === 0) {
const error = new Error('Socket timeout');
error.code = 'ERR_SOCKET_TIMEOUT';
error.timeout = timeout;
// must manually call socket.end() or socket.destroy() to end the connection.
// https://nodejs.org/dist/latest-v10.x/docs/api/net.html#net_socket_settimeout_timeout_callback
socket.destroy(error);
agent.removeSocket(socket, options);
debug('%s destroy with timeout error', socket[SOCKET_NAME]);
}
}
}
socket.on('timeout', onTimeout);
function onError(err) {
const listenerCount = socket.listeners('error').length;
debug('%s(requests: %s, finished: %s) error: %s, listenerCount: %s',
socket[SOCKET_NAME], socket[SOCKET_REQUEST_COUNT], socket[SOCKET_REQUEST_FINISHED_COUNT],
err, listenerCount);
agent.errorSocketCount++;
if (listenerCount === 1) {
// if socket don't contain error event handler, don't catch it, emit it again
debug('%s emit uncaught error event', socket[SOCKET_NAME]);
socket.removeListener('error', onError);
socket.emit('error', err);
}
}
socket.on('error', onError);
function onRemove() {
debug('%s(requests: %s, finished: %s) agentRemove',
socket[SOCKET_NAME],
socket[SOCKET_REQUEST_COUNT], socket[SOCKET_REQUEST_FINISHED_COUNT]);
// We need this function for cases like HTTP 'upgrade'
// (defined by WebSockets) where we need to remove a socket from the
// pool because it'll be locked up indefinitely
socket.removeListener('close', onClose);
socket.removeListener('error', onError);
socket.removeListener('free', onFree);
socket.removeListener('timeout', onTimeout);
socket.removeListener('agentRemove', onRemove);
}
socket.on('agentRemove', onRemove);
}
module.exports = Agent;
function inspect(obj) {
const res = {};
for (const key in obj) {
res[key] = obj[key].length;
}
return res;
}