Skip to content

Commit 2e3463c

Browse files
authored
fix: tweak keep-alive timeout implementation (#3145)
* fix: tweak keep-alive timeout implementation Closes #3141 * fix test impacted by default keepAliveTimeoutThreshold change * adjust timer registration strategy * only use fast timers with delay > 1000ms
1 parent 9458ffd commit 2e3463c

File tree

5 files changed

+86
-6
lines changed

5 files changed

+86
-6
lines changed

docs/docs/api/Client.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Returns: `Client`
2323
* **headersTimeout** `number | null` (optional) - Default: `300e3` - The amount of time, in milliseconds, the parser will wait to receive the complete HTTP headers while not sending the request. Defaults to 300 seconds.
2424
* **keepAliveMaxTimeout** `number | null` (optional) - Default: `600e3` - The maximum allowed `keepAliveTimeout`, in milliseconds, when overridden by *keep-alive* hints from the server. Defaults to 10 minutes.
2525
* **keepAliveTimeout** `number | null` (optional) - Default: `4e3` - The timeout, in milliseconds, after which a socket without active requests will time out. Monitors time between activity on a connected socket. This value may be overridden by *keep-alive* hints from the server. See [MDN: HTTP - Headers - Keep-Alive directives](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Keep-Alive#directives) for more details. Defaults to 4 seconds.
26-
* **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `1e3` - A number of milliseconds subtracted from server *keep-alive* hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 1 second.
26+
* **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `2e3` - A number of milliseconds subtracted from server *keep-alive* hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 2 seconds.
2727
* **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB.
2828
* **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
2929
* **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections.

lib/dispatcher/client.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ class Client extends DispatcherBase {
226226
this[kMaxHeadersSize] = maxHeaderSize || http.maxHeaderSize
227227
this[kKeepAliveDefaultTimeout] = keepAliveTimeout == null ? 4e3 : keepAliveTimeout
228228
this[kKeepAliveMaxTimeout] = keepAliveMaxTimeout == null ? 600e3 : keepAliveMaxTimeout
229-
this[kKeepAliveTimeoutThreshold] = keepAliveTimeoutThreshold == null ? 1e3 : keepAliveTimeoutThreshold
229+
this[kKeepAliveTimeoutThreshold] = keepAliveTimeoutThreshold == null ? 2e3 : keepAliveTimeoutThreshold
230230
this[kKeepAliveTimeoutValue] = this[kKeepAliveDefaultTimeout]
231231
this[kServerName] = null
232232
this[kLocalAddress] = localAddress != null ? localAddress : null

lib/util/timers.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict'
22

3+
const TICK_MS = 499
4+
35
let fastNow = Date.now()
46
let fastNowTimeout
57

@@ -14,7 +16,7 @@ function onTimeout () {
1416
const timer = fastTimers[idx]
1517

1618
if (timer.state === 0) {
17-
timer.state = fastNow + timer.delay
19+
timer.state = fastNow + timer.delay - TICK_MS
1820
} else if (timer.state > 0 && fastNow >= timer.state) {
1921
timer.state = -1
2022
timer.callback(timer.opaque)
@@ -43,7 +45,7 @@ function refreshTimeout () {
4345
fastNowTimeout.refresh()
4446
} else {
4547
clearTimeout(fastNowTimeout)
46-
fastNowTimeout = setTimeout(onTimeout, 1e3)
48+
fastNowTimeout = setTimeout(onTimeout, TICK_MS)
4749
if (fastNowTimeout.unref) {
4850
fastNowTimeout.unref()
4951
}
@@ -83,7 +85,7 @@ class Timeout {
8385

8486
module.exports = {
8587
setTimeout (callback, delay, opaque) {
86-
return delay < 1e3
88+
return delay <= 1e3
8789
? setTimeout(callback, delay, opaque)
8890
: new Timeout(callback, delay, opaque)
8991
},

test/node-test/balanced-pool.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ describe('weighted round robin', () => {
491491
const urls = servers.map(server => `http://localhost:${server.port}`)
492492

493493
// add upstreams
494-
const client = new BalancedPool(urls[0], { maxWeightPerServer, errorPenalty })
494+
const client = new BalancedPool(urls[0], { maxWeightPerServer, errorPenalty, keepAliveTimeoutThreshold: 100 })
495495
urls.slice(1).map(url => client.addUpstream(url))
496496

497497
let connectionRefusedErrors = 0

test/util.js

+78
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use strict'
22

3+
const { tspl } = require('@matteo.collina/tspl')
34
const { strictEqual, throws, doesNotThrow } = require('node:assert')
45
const { test, describe } = require('node:test')
56
const { isBlobLike, parseURL, isHttpOrHttpsPrefixed, isValidPort } = require('../lib/core/util')
67
const { Blob, File } = require('node:buffer')
78
const { InvalidArgumentError } = require('../lib/core/errors')
9+
const timers = require('../lib/util/timers')
810

911
describe('isBlobLike', () => {
1012
test('buffer', () => {
@@ -253,3 +255,79 @@ describe('parseURL', () => {
253255
})
254256
})
255257
})
258+
259+
describe('timers', () => {
260+
const getDelta = (start, target) => {
261+
const end = process.hrtime.bigint()
262+
const actual = (end - start) / 1_000_000n
263+
return actual - BigInt(target)
264+
}
265+
266+
// timers.setTimeout impelements a low resolution timer with a 500 ms granularity
267+
// It is expected that in the worst case, a timer will fire about 500 ms after the
268+
// intended amount of time, an extra 200 ms is added to account event loop overhead
269+
// Timers should never fire excessively early, 1ms early is tolerated
270+
const ACCEPTABLE_DELTA = 700n
271+
272+
test('meet acceptable resolution time', async (t) => {
273+
const testTimeouts = [0, 1, 499, 500, 501, 990, 999, 1000, 1001, 1100, 1400, 1499, 1500, 4000, 5000]
274+
275+
t = tspl(t, { plan: 1 + testTimeouts.length * 2 })
276+
277+
const start = process.hrtime.bigint()
278+
279+
for (const target of testTimeouts) {
280+
timers.setTimeout(() => {
281+
const delta = getDelta(start, target)
282+
283+
t.ok(delta >= -1n, `${target}ms fired early`)
284+
t.ok(delta < ACCEPTABLE_DELTA, `${target}ms fired late`)
285+
}, target)
286+
}
287+
288+
setTimeout(() => t.ok(true), 6000)
289+
await t.completed
290+
})
291+
292+
test('refresh correctly with timeout < TICK_MS', async (t) => {
293+
t = tspl(t, { plan: 3 })
294+
295+
const start = process.hrtime.bigint()
296+
297+
const timeout = timers.setTimeout(() => {
298+
// 400 ms timer was refreshed after 600ms; total target is 1000
299+
const delta = getDelta(start, 1000)
300+
301+
t.ok(delta >= -1n, 'refreshed timer fired early')
302+
t.ok(delta < ACCEPTABLE_DELTA, 'refreshed timer fired late')
303+
}, 400)
304+
305+
setTimeout(() => timeout.refresh(), 200)
306+
setTimeout(() => timeout.refresh(), 400)
307+
setTimeout(() => timeout.refresh(), 600)
308+
309+
setTimeout(() => t.ok(true), 1500)
310+
await t.completed
311+
})
312+
313+
test('refresh correctly with timeout > TICK_MS', async (t) => {
314+
t = tspl(t, { plan: 3 })
315+
316+
const start = process.hrtime.bigint()
317+
318+
const timeout = timers.setTimeout(() => {
319+
// 501ms timer was refreshed after 1250ms; total target is 1751
320+
const delta = getDelta(start, 1751)
321+
322+
t.ok(delta >= -1n, 'refreshed timer fired early')
323+
t.ok(delta < ACCEPTABLE_DELTA, 'refreshed timer fired late')
324+
}, 501)
325+
326+
setTimeout(() => timeout.refresh(), 250)
327+
setTimeout(() => timeout.refresh(), 750)
328+
setTimeout(() => timeout.refresh(), 1250)
329+
330+
setTimeout(() => t.ok(true), 3000)
331+
await t.completed
332+
})
333+
})

0 commit comments

Comments
 (0)