Skip to content

Should Fetch retry reusable request bodies after HTTP/2 GOAWAY? #5912

Open

Description

@fallintoplace

This would solve...

When an HTTP/2 server sends GOAWAY, it can say that a request stream was not processed. Undici can retry some requests in this case. With Fetch, a string body becomes a one-use stream. The retry code cannot send it again, so the request times out.

The example sends foo=bar on stream 1. GOAWAY says that stream 1 was not processed. On current main, Fetch times out and does not open a second stream.

Reproduction

Run this from the Undici repo root after installing the development dependencies:

const { constants, createSecureServer } = require('node:http2')
const { once } = require('node:events')
const pem = require('@metcoder95/https-pem')
const { Client, fetch } = require('./')

async function main () {
  const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }))
  let streams = 0
  let received = ''

  server.on('session', session => session.on('error', () => {}))
  server.on('stream', (stream) => {
    stream.on('error', () => {})

    if (++streams === 1) {
      stream.session.goaway(constants.NGHTTP2_NO_ERROR, 0)
      stream.resume()
      return
    }

    stream.setEncoding('utf8')
    stream.on('data', chunk => { received += chunk })
    stream.on('end', () => {
      stream.respond({ ':status': 200 })
      stream.end('ok')
    })
  })

  server.listen(0)
  await once(server, 'listening')

  const url = `https://localhost:${server.address().port}/`
  const client = new Client(url, {
    allowH2: true,
    connect: { rejectUnauthorized: false }
  })

  try {
    const response = await fetch(url, {
      method: 'POST',
      body: 'foo=bar',
      dispatcher: client,
      signal: AbortSignal.timeout(5000)
    })
    console.log(response.status, { streams, received })
  } catch (error) {
    console.log(error.name, { streams, received })
  } finally {
    await client.destroy()
    await new Promise(resolve => server.close(resolve))
  }
}

main()

On current main, this prints TimeoutError with streams: 1 and an empty received value. I expect it to print status 200 with streams: 2 and received: 'foo=bar'.

The implementation should look like...

Fetch could create a new stream for bodies that always produce the same bytes, such as strings, byte arrays, and Blobs. ReadableStream and async iterable bodies should stay one-use.

I have also considered...

Recreating FormData may create a new multipart boundary. The body could then disagree with the existing Content-Type. Should FormData stay non-replayable unless Undici keeps the original body and boundary? Should this also work through RetryAgent?

Additional context

This came from PR #5909, which was closed with a request to open an issue first. Issue #3433 is about a later request after GOAWAY. This issue is about retrying the request GOAWAY says was not processed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions