Skip to content

Commit fd01347

Browse files
authored
fix: dual-stack retries infinite loop (nodejs#4001)
1 parent 45eaed2 commit fd01347

File tree

2 files changed

+129
-30
lines changed

2 files changed

+129
-30
lines changed

lib/interceptor/dns.js

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class DNSInstance {
3232

3333
// If full, we just return the origin
3434
if (ips == null && this.full) {
35-
cb(null, origin.origin)
35+
cb(null, origin)
3636
return
3737
}
3838

@@ -74,9 +74,9 @@ class DNSInstance {
7474

7575
cb(
7676
null,
77-
`${origin.protocol}//${
77+
new URL(`${origin.protocol}//${
7878
ip.family === 6 ? `[${ip.address}]` : ip.address
79-
}${port}`
79+
}${port}`)
8080
)
8181
})
8282
} else {
@@ -105,9 +105,9 @@ class DNSInstance {
105105

106106
cb(
107107
null,
108-
`${origin.protocol}//${
108+
new URL(`${origin.protocol}//${
109109
ip.family === 6 ? `[${ip.address}]` : ip.address
110-
}${port}`
110+
}${port}`)
111111
)
112112
}
113113
}
@@ -192,6 +192,38 @@ class DNSInstance {
192192
return ip
193193
}
194194

195+
pickFamily (origin, ipFamily) {
196+
const records = this.#records.get(origin.hostname)?.records
197+
if (!records) {
198+
return null
199+
}
200+
201+
const family = records[ipFamily]
202+
if (!family) {
203+
return null
204+
}
205+
206+
if (family.offset == null || family.offset === maxInt) {
207+
family.offset = 0
208+
} else {
209+
family.offset++
210+
}
211+
212+
const position = family.offset % family.ips.length
213+
const ip = family.ips[position] ?? null
214+
if (ip == null) {
215+
return ip
216+
}
217+
218+
if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms
219+
// We delete expired records
220+
// It is possible that they have different TTL, so we manage them individually
221+
family.ips.splice(position, 1)
222+
}
223+
224+
return ip
225+
}
226+
195227
setRecords (origin, addresses) {
196228
const timestamp = Date.now()
197229
const records = { records: { 4: null, 6: null } }
@@ -228,10 +260,13 @@ class DNSDispatchHandler extends DecoratorHandler {
228260
#dispatch = null
229261
#origin = null
230262
#controller = null
263+
#newOrigin = null
264+
#firstTry = true
231265

232-
constructor (state, { origin, handler, dispatch }, opts) {
266+
constructor (state, { origin, handler, dispatch, newOrigin }, opts) {
233267
super(handler)
234268
this.#origin = origin
269+
this.#newOrigin = newOrigin
235270
this.#opts = { ...opts }
236271
this.#state = state
237272
this.#dispatch = dispatch
@@ -242,21 +277,36 @@ class DNSDispatchHandler extends DecoratorHandler {
242277
case 'ETIMEDOUT':
243278
case 'ECONNREFUSED': {
244279
if (this.#state.dualStack) {
245-
// We delete the record and retry
246-
this.#state.runLookup(this.#origin, this.#opts, (err, newOrigin) => {
247-
if (err) {
248-
super.onResponseError(controller, err)
249-
return
250-
}
251-
252-
const dispatchOpts = {
253-
...this.#opts,
254-
origin: newOrigin
255-
}
280+
if (!this.#firstTry) {
281+
super.onResponseError(controller, err)
282+
return
283+
}
284+
this.#firstTry = false
285+
286+
// Pick an ip address from the other family
287+
const otherFamily = this.#newOrigin.hostname[0] === '[' ? 4 : 6
288+
const ip = this.#state.pickFamily(this.#origin, otherFamily)
289+
if (ip == null) {
290+
super.onResponseError(controller, err)
291+
return
292+
}
256293

257-
this.#dispatch(dispatchOpts, this)
258-
})
294+
let port
295+
if (typeof ip.port === 'number') {
296+
port = `:${ip.port}`
297+
} else if (this.#origin.port !== '') {
298+
port = `:${this.#origin.port}`
299+
} else {
300+
port = ''
301+
}
259302

303+
const dispatchOpts = {
304+
...this.#opts,
305+
origin: `${this.#origin.protocol}//${
306+
ip.family === 6 ? `[${ip.address}]` : ip.address
307+
}${port}`
308+
}
309+
this.#dispatch(dispatchOpts, this)
260310
return
261311
}
262312

@@ -266,7 +316,8 @@ class DNSDispatchHandler extends DecoratorHandler {
266316
}
267317
case 'ENOTFOUND':
268318
this.#state.deleteRecords(this.#origin)
269-
// eslint-disable-next-line no-fallthrough
319+
super.onResponseError(controller, err)
320+
break
270321
default:
271322
super.onResponseError(controller, err)
272323
break
@@ -356,11 +407,10 @@ module.exports = interceptorOpts => {
356407
return handler.onResponseError(null, err)
357408
}
358409

359-
let dispatchOpts = null
360-
dispatchOpts = {
410+
const dispatchOpts = {
361411
...origDispatchOpts,
362412
servername: origin.hostname, // For SNI on TLS
363-
origin: newOrigin,
413+
origin: newOrigin.origin,
364414
headers: {
365415
host: origin.host,
366416
...origDispatchOpts.headers
@@ -369,7 +419,10 @@ module.exports = interceptorOpts => {
369419

370420
dispatch(
371421
dispatchOpts,
372-
instance.getHandler({ origin, dispatch, handler }, origDispatchOpts)
422+
instance.getHandler(
423+
{ origin, dispatch, handler, newOrigin },
424+
origDispatchOpts
425+
)
373426
)
374427
})
375428

test/interceptors/dns.js

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ test('Should respect DNS origin hostname for SNI on TLS', async t => {
198198
})
199199

200200
test('Should recover on network errors (dual stack - 4)', async t => {
201-
t = tspl(t, { plan: 8 })
201+
t = tspl(t, { plan: 7 })
202202

203203
let counter = 0
204204
const server = createServer()
@@ -236,11 +236,6 @@ test('Should recover on network errors (dual stack - 4)', async t => {
236236
break
237237

238238
case 3:
239-
// [::1] -> ::1
240-
t.equal(isIP(url.hostname), 4)
241-
break
242-
243-
case 4:
244239
// [::1] -> ::1
245240
t.equal(isIP(url.hostname.slice(1, 4)), 6)
246241
break
@@ -1732,6 +1727,57 @@ test('Should handle max cached items', async t => {
17321727
t.equal(await response3.body.text(), 'hello world! (x2)')
17331728
})
17341729

1730+
test('retry once with dual-stack', async t => {
1731+
t = tspl(t, { plan: 2 })
1732+
1733+
const requestOptions = {
1734+
method: 'GET',
1735+
path: '/',
1736+
headers: {
1737+
'content-type': 'application/json'
1738+
}
1739+
}
1740+
1741+
let counter = 0
1742+
const client = new Agent().compose([
1743+
dispatch => {
1744+
return (opts, handler) => {
1745+
counter++
1746+
return dispatch(opts, handler)
1747+
}
1748+
},
1749+
dns({
1750+
lookup: (_origin, _opts, cb) => {
1751+
cb(null, [
1752+
{
1753+
address: '127.0.0.1',
1754+
port: 3669,
1755+
family: 4,
1756+
ttl: 1000
1757+
},
1758+
{
1759+
address: '::1',
1760+
port: 3669,
1761+
family: 6,
1762+
ttl: 1000
1763+
}
1764+
])
1765+
}
1766+
})
1767+
])
1768+
1769+
after(async () => {
1770+
await client.close()
1771+
})
1772+
1773+
await t.rejects(client.request({
1774+
...requestOptions,
1775+
origin: 'http://localhost'
1776+
}), 'ECONNREFUSED')
1777+
1778+
t.equal(counter, 2)
1779+
})
1780+
17351781
test('Should handle ENOTFOUND response error', async t => {
17361782
t = tspl(t, { plan: 3 })
17371783
let lookupCounter = 0

0 commit comments

Comments
 (0)