Skip to content

Commit f4fbca7

Browse files
committed
fix fetch retry on closed h2 session
Signed-off-by: marko1olo <barsukdana@gmail.com>
1 parent ee59da3 commit f4fbca7

2 files changed

Lines changed: 126 additions & 13 deletions

File tree

lib/web/fetch/index.js

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ const { bytesMatch } = require('../subresource-integrity/subresource-integrity')
6767
const { isomorphicEncode } = require('../infra')
6868

6969
const GET_OR_HEAD = ['GET', 'HEAD']
70+
const retriableFetchNetworkErrorCodes = new Set([
71+
'ECONNRESET',
72+
'EPIPE',
73+
'UND_ERR_SOCKET'
74+
])
75+
const kFetchNetworkErrorRetry = Symbol('fetch network error retry')
7076

7177
const defaultUserAgent = typeof __UNDICI_IS_NODE__ !== 'undefined' || typeof esbuildDetection !== 'undefined'
7278
? 'node'
@@ -151,6 +157,34 @@ class Fetch extends EE {
151157
}
152158
}
153159

160+
function createFetchConnection (fetchParams) {
161+
fetchParams.controller.connection = {
162+
abort: null,
163+
destroyed: false,
164+
destroy (err, abort = true) {
165+
if (!this.destroyed) {
166+
this.destroyed = true
167+
if (abort) {
168+
this.abort?.(err ?? new DOMException('The operation was aborted.', 'AbortError'))
169+
}
170+
}
171+
}
172+
}
173+
}
174+
175+
function canReplayRequestBody (request) {
176+
return request.body == null || request.body.source != null
177+
}
178+
179+
function isRetriableFetchNetworkError (error) {
180+
return retriableFetchNetworkErrorCodes.has(error?.code) ||
181+
retriableFetchNetworkErrorCodes.has(error?.cause?.code)
182+
}
183+
184+
function waitForFetchRetry () {
185+
return new Promise(resolve => setTimeout(resolve, 0))
186+
}
187+
154188
function handleFetchDone (response) {
155189
finalizeAndReportTiming(response, 'fetch')
156190
}
@@ -1815,18 +1849,7 @@ async function httpNetworkFetch (
18151849
) {
18161850
assert(!fetchParams.controller.connection || fetchParams.controller.connection.destroyed)
18171851

1818-
fetchParams.controller.connection = {
1819-
abort: null,
1820-
destroyed: false,
1821-
destroy (err, abort = true) {
1822-
if (!this.destroyed) {
1823-
this.destroyed = true
1824-
if (abort) {
1825-
this.abort?.(err ?? new DOMException('The operation was aborted.', 'AbortError'))
1826-
}
1827-
}
1828-
}
1829-
}
1852+
createFetchConnection(fetchParams)
18301853

18311854
// 1. Let request be fetchParams’s request.
18321855
const request = fetchParams.request
@@ -2000,6 +2023,22 @@ async function httpNetworkFetch (
20002023
response = makeResponse({ status, statusText, headersList })
20012024
}
20022025
} catch (err) {
2026+
if (err?.[kFetchNetworkErrorRetry]) {
2027+
fetchParams.controller.connection.destroy(err, false)
2028+
2029+
await waitForFetchRetry()
2030+
2031+
if (isCancelled(fetchParams)) {
2032+
return makeAppropriateNetworkError(fetchParams, err)
2033+
}
2034+
2035+
if (request.body != null) {
2036+
request.body = safelyExtractBody(request.body.source)[0]
2037+
}
2038+
2039+
return httpNetworkFetch(fetchParams, includeCredentials, true)
2040+
}
2041+
20032042
// 10. If aborted, then:
20042043
if (err.name === 'AbortError') {
20052044
// 1. If connection uses HTTP/2, then transmit an RST_STREAM frame.
@@ -2376,6 +2415,18 @@ async function httpNetworkFetch (
23762415
return
23772416
}
23782417

2418+
if (
2419+
this.body == null &&
2420+
!forceNewConnection &&
2421+
request.mode !== 'websocket' &&
2422+
canReplayRequestBody(request) &&
2423+
isRetriableFetchNetworkError(error)
2424+
) {
2425+
error[kFetchNetworkErrorRetry] = true
2426+
reject(error)
2427+
return
2428+
}
2429+
23792430
this.body?.destroy(error)
23802431

23812432
fetchParams.controller.terminate(error)

test/fetch/http2.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const { Readable } = require('node:stream')
99
const { test } = require('node:test')
1010
const pem = require('@metcoder95/https-pem')
1111

12-
const { Client, fetch, Headers } = require('../..')
12+
const { Agent, Client, fetch, Headers } = require('../..')
1313

1414
const { closeClientAndServerAsPromise } = require('../utils/node-http')
1515

@@ -123,6 +123,68 @@ test('[Fetch] Simple GET with h2', async (t) => {
123123
t.assert.strictEqual(response.statusText, '')
124124
})
125125

126+
test('[Fetch] retries h2 fetch after idle session socket closes', async (t) => {
127+
const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }))
128+
const requestBodies = []
129+
const sockets = []
130+
let streams = 0
131+
132+
server.on('secureConnection', (socket) => {
133+
socket.on('error', () => {})
134+
sockets.push(socket)
135+
})
136+
server.on('sessionError', () => {})
137+
server.on('tlsClientError', () => {})
138+
server.on('stream', async (stream) => {
139+
streams++
140+
const chunks = []
141+
142+
for await (const chunk of stream) {
143+
chunks.push(chunk)
144+
}
145+
146+
requestBodies.push(Buffer.concat(chunks).toString())
147+
stream.respond({ ':status': 200 })
148+
stream.end('ok')
149+
})
150+
151+
t.plan(4)
152+
153+
server.listen(0)
154+
await once(server, 'listening')
155+
156+
const dispatcher = new Agent({
157+
connections: 1,
158+
connect: {
159+
rejectUnauthorized: false
160+
},
161+
allowH2: true
162+
})
163+
164+
t.after(async () => {
165+
for (const socket of sockets) {
166+
socket.destroy()
167+
}
168+
169+
await dispatcher.close()
170+
server.close()
171+
})
172+
173+
const first = await fetch(`https://localhost:${server.address().port}/`, { dispatcher })
174+
t.assert.strictEqual(await first.text(), 'ok')
175+
176+
sockets[0].destroy()
177+
178+
const second = await fetch(`https://localhost:${server.address().port}/`, {
179+
dispatcher,
180+
method: 'POST',
181+
body: 'replayed body'
182+
})
183+
t.assert.strictEqual(await second.text(), 'ok')
184+
t.assert.strictEqual(streams, 2)
185+
t.assert.strictEqual(requestBodies[1], 'replayed body')
186+
})
187+
126188
test('[Fetch] Should handle h2 request with body (string or buffer)', async (t) => {
127189
const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }))
128190
const expectedBody = 'hello from client!'

0 commit comments

Comments
 (0)