Skip to content

Commit ca52d9b

Browse files
authored
Fix Auto Requests crashing after client network disconnect (#98)
1 parent 6a0f945 commit ca52d9b

10 files changed

+524
-13
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,7 @@ request.defaults({
976976
[linux-timeout]: http://www.sekuda.com/overriding_the_default_linux_kernel_20_second_tcp_socket_connect_timeout
977977

978978
- `maxResponseSize` - Abort request if the response size exceeds this threshold (bytes).
979+
- `agentIdleTimeout` - set to number of milliseconds after which the agent should be discarded for reuse
979980
---
980981

981982
- `localAddress` - local interface to bind for network connections.

lib/autohttp/request.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class MultiProtocolRequest extends EventEmitter {
6666
ob.on('end', (...args) => this.emit('end', ...args))
6767
ob.on('close', (...args) => this.emit('close', ...args))
6868
ob.on('response', (...args) => this.emit('response', ...args))
69-
ob.once('error', (...args) => this.emit('error', ...args))
69+
ob.on('error', (...args) => this.emit('error', ...args))
7070
}
7171

7272
processQueuedOpens () {

lib/http2/agent.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,23 +42,25 @@ class Http2Agent extends EventEmitter {
4242
const oldRef = connection.ref
4343
const oldUnref = connection.unref
4444

45+
const timeoutHandler = () => {
46+
delete connectionsMap[name]
47+
connection.close()
48+
}
49+
4550
connection.refCount = 0
4651
connection.ref = function () {
4752
this.refCount++
4853
oldRef.call(this)
49-
connection.removeAllListeners('timeout')
54+
connection.off('timeout', timeoutHandler)
5055
connection.setTimeout(0)
5156
}
5257
const connectionsMap = this.connections
5358
connection.unref = function () {
5459
this.refCount--
5560
if (this.refCount === 0) {
5661
oldUnref.call(this)
57-
if (_options.sessionIdleTimeout) {
58-
connection.setTimeout(_options.sessionIdleTimeout, () => {
59-
connection.close()
60-
delete connectionsMap[name]
61-
})
62+
if (_options.timeout) {
63+
connection.setTimeout(_options.timeout, timeoutHandler)
6264
}
6365
}
6466
}

lib/http2/request.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class Http2Request extends EventEmitter {
4242
this.onClose = this.onClose.bind(this)
4343
this.onResponse = this.onResponse.bind(this)
4444
this.onEnd = this.onEnd.bind(this)
45+
this.onTimeout = this.onTimeout.bind(this)
4546

4647
this.registerListeners = this.registerListeners.bind(this)
4748
this._flushHeaders = this._flushHeaders.bind(this)
@@ -102,6 +103,7 @@ class Http2Request extends EventEmitter {
102103
this.stream.on('close', this.onClose)
103104
this.stream.on('response', this.onResponse)
104105
this.stream.on('end', this.onEnd)
106+
this.stream.on('timeout', this.onTimeout)
105107
}
106108

107109
onDrain (...args) {
@@ -120,9 +122,14 @@ class Http2Request extends EventEmitter {
120122
this.emit('end')
121123
}
122124

125+
onTimeout () {
126+
this.stream.close()
127+
}
128+
123129
onClose (...args) {
124130
if (this.stream.rstCode) {
125131
// Emit error message in case of abnormal stream closure
132+
// It is fine if the error is emitted multiple times, since the callback has checks to prevent multiple invocations
126133
this.onError(new Error(`HTTP/2 Stream closed with error code ${rstErrorCodesMap[this.stream.rstCode]}`))
127134
}
128135

@@ -134,6 +141,7 @@ class Http2Request extends EventEmitter {
134141
this.stream.off('response', this.onResponse)
135142
this.stream.off('end', this.onEnd)
136143
this.stream.off('close', this.onClose)
144+
this.stream.off('timeout', this.onTimeout)
137145

138146
this.removeAllListeners()
139147
}

request.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,7 @@ Request.prototype.init = function (options) {
649649
self.agent = false
650650
} else {
651651
try {
652-
self.agent = self.agent || self.getNewAgent()
652+
self.agent = self.agent || self.getNewAgent({agentIdleTimeout: options.agentIdleTimeout})
653653
} catch (error) {
654654
// tls.createSecureContext() throws on bad options
655655
return self.emit('error', error)
@@ -774,7 +774,7 @@ Request.prototype.init = function (options) {
774774
})
775775
}
776776

777-
Request.prototype.getNewAgent = function () {
777+
Request.prototype.getNewAgent = function ({agentIdleTimeout}) {
778778
var self = this
779779
var Agent = self.agentClass
780780
var options = {}
@@ -900,24 +900,27 @@ Request.prototype.getNewAgent = function () {
900900
}
901901
}
902902

903-
if (self.pool === globalPool && !poolKey && Object.keys(options).length === 0 && self.httpModule.globalAgent) {
903+
if (self.pool === globalPool && !poolKey && Object.keys(options).length === 0 && self.httpModule.globalAgent && typeof agentIdleTimeout !== 'number') {
904904
// not doing anything special. Use the globalAgent
905905
return self.httpModule.globalAgent
906906
}
907907

908908
// we're using a stored agent. Make sure it's protocol-specific
909909
poolKey = self.protocolVersion + ':' + self.uri.protocol + poolKey
910910

911+
let agent = self.pool[poolKey]
912+
911913
// generate a new agent for this setting if none yet exists
912-
if (!self.pool[poolKey]) {
913-
self.pool[poolKey] = new Agent(options)
914+
if (!agent || (typeof agentIdleTimeout === 'number' && (agent.lastUsedAt || 0) + agentIdleTimeout < Date.now())) {
915+
agent = self.pool[poolKey] = new Agent(options)
914916
// properly set maxSockets on new agents
915917
if (self.pool.maxSockets) {
916918
self.pool[poolKey].maxSockets = self.pool.maxSockets
917919
}
918920
}
919921

920-
return self.pool[poolKey]
922+
agent.lastUsedAt = Date.now()
923+
return agent
921924
}
922925

923926
Request.prototype.start = function () {

tests/test-agentIdleTimeout.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use strict'
2+
3+
var server = require('./server')
4+
var request = require('../index')
5+
var tape = require('tape')
6+
7+
var s
8+
9+
function createServer () {
10+
const s = server.createServer()
11+
12+
s.on('/', function (req, res) {
13+
res.writeHead(200, { 'content-type': 'text/plain' })
14+
res.end()
15+
})
16+
17+
return s
18+
}
19+
20+
tape('setup', function (t) {
21+
s = createServer()
22+
s.listen(0, function () {
23+
t.end()
24+
})
25+
})
26+
27+
tape('should reuse the same agent', function (t) {
28+
const data = {
29+
url: s.url + '/',
30+
agentIdleTimeout: 1000
31+
}
32+
33+
const r1 = request(data, function (err, res) {
34+
t.equal(err, null)
35+
t.equal(res.statusCode, 200)
36+
const r2 = request(data, function (err) {
37+
t.equal(err, null)
38+
t.end()
39+
t.equal(r1.agent.identifier, r2.agent.identifier)
40+
})
41+
})
42+
r1.agent.identifier = '1234'
43+
})
44+
45+
tape('should use new agent after timeout', function (t) {
46+
const data = {
47+
url: s.url + '/',
48+
agentIdleTimeout: 100
49+
}
50+
51+
const r1 = request(data, function (err, res) {
52+
t.equal(err, null)
53+
t.equal(res.statusCode, 200)
54+
setTimeout(() => {
55+
const r2 = request(data, function (err) {
56+
t.equal(err, null)
57+
t.end()
58+
t.notEqual(r1.agent.identifier, r2.agent.identifier)
59+
})
60+
}, 200)
61+
})
62+
r1.agent.identifier = '12345'
63+
})
64+
65+
tape('cleanup', function (t) {
66+
s.close(function () {
67+
t.end()
68+
})
69+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use strict'
2+
3+
var request = require('../index')
4+
var tape = require('tape')
5+
var server = require('./server')
6+
var destroyable = require('server-destroy')
7+
8+
const s = server.createHttp2Server()
9+
10+
destroyable(s)
11+
12+
tape('setup', function (t) {
13+
s.listen(0, function () {
14+
t.end()
15+
})
16+
})
17+
18+
function addTest (errorCode, data = {}) {
19+
tape('test ' + errorCode, function (t) {
20+
s.on('/' + errorCode, function (req, res) {
21+
if (errorCode === 0) {
22+
res.end()
23+
return
24+
}
25+
res.stream.close(errorCode)
26+
})
27+
data.uri = s.url + '/' + errorCode
28+
request(
29+
{ ...data, strictSSL: false, protocolVersion: 'auto' },
30+
function (err) {
31+
if (errorCode === 0) {
32+
t.equal(err, null)
33+
t.end()
34+
return
35+
}
36+
if (errorCode === 8) {
37+
t.equal(err.message, `HTTP/2 Stream closed with error code NGHTTP2_CANCEL`)
38+
t.end()
39+
return
40+
}
41+
t.equal(err.message, `Stream closed with error code ${errorCodes[errorCode]}`)
42+
t.end()
43+
}
44+
)
45+
})
46+
}
47+
48+
const errorCodes = [
49+
'NGHTTP2_NO_ERROR',
50+
'NGHTTP2_PROTOCOL_ERROR',
51+
'NGHTTP2_INTERNAL_ERROR',
52+
'NGHTTP2_FLOW_CONTROL_ERROR',
53+
'NGHTTP2_SETTINGS_TIMEOUT',
54+
'NGHTTP2_STREAM_CLOSED',
55+
'NGHTTP2_FRAME_SIZE_ERROR',
56+
'NGHTTP2_REFUSED_STREAM',
57+
'NGHTTP2_CANCEL',
58+
'NGHTTP2_COMPRESSION_ERROR',
59+
'NGHTTP2_CONNECT_ERROR',
60+
'NGHTTP2_ENHANCE_YOUR_CALM',
61+
'NGHTTP2_INADEQUATE_SECURITY',
62+
'NGHTTP2_HTTP_1_1_REQUIRED'
63+
]
64+
65+
for (let i = 0; i < errorCodes.length; i++) {
66+
addTest(i)
67+
}
68+
69+
tape('cleanup', function (t) {
70+
s.destroy(function () {
71+
t.end()
72+
})
73+
})

tests/test-errors-http2-specific.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use strict'
2+
3+
var request = require('../index')
4+
var tape = require('tape')
5+
var server = require('./server')
6+
var destroyable = require('server-destroy')
7+
8+
const s = server.createHttp2Server()
9+
10+
destroyable(s)
11+
12+
tape('setup', function (t) {
13+
s.listen(0, function () {
14+
t.end()
15+
})
16+
})
17+
18+
function addTest (errorCode, data = {}) {
19+
tape('test ' + errorCode, function (t) {
20+
s.on('/' + errorCode, function (req, res) {
21+
if (errorCode === 0) {
22+
res.end()
23+
return
24+
}
25+
res.stream.close(errorCode)
26+
})
27+
data.uri = s.url + '/' + errorCode
28+
request(
29+
{ ...data, strictSSL: false, protocolVersion: 'http2' },
30+
function (err, resp, body) {
31+
if (errorCode === 0) {
32+
t.equal(err, null)
33+
t.end()
34+
return
35+
}
36+
if (errorCode === 8) {
37+
t.equal(err.message, `HTTP/2 Stream closed with error code NGHTTP2_CANCEL`)
38+
t.end()
39+
return
40+
}
41+
t.equal(err.message, `Stream closed with error code ${errorCodes[errorCode]}`)
42+
t.end()
43+
}
44+
)
45+
})
46+
}
47+
48+
const errorCodes = [
49+
'NGHTTP2_NO_ERROR',
50+
'NGHTTP2_PROTOCOL_ERROR',
51+
'NGHTTP2_INTERNAL_ERROR',
52+
'NGHTTP2_FLOW_CONTROL_ERROR',
53+
'NGHTTP2_SETTINGS_TIMEOUT',
54+
'NGHTTP2_STREAM_CLOSED',
55+
'NGHTTP2_FRAME_SIZE_ERROR',
56+
'NGHTTP2_REFUSED_STREAM',
57+
'NGHTTP2_CANCEL',
58+
'NGHTTP2_COMPRESSION_ERROR',
59+
'NGHTTP2_CONNECT_ERROR',
60+
'NGHTTP2_ENHANCE_YOUR_CALM',
61+
'NGHTTP2_INADEQUATE_SECURITY',
62+
'NGHTTP2_HTTP_1_1_REQUIRED'
63+
]
64+
65+
for (let i = 0; i < errorCodes.length; i++) {
66+
addTest(i)
67+
}
68+
69+
tape('cleanup', function (t) {
70+
s.destroy(function () {
71+
t.end()
72+
})
73+
})

0 commit comments

Comments
 (0)