@ 261107.5 · fetching
current
'use strict'

const { tspl } = require('@matteo.collina/tspl')
const { test } = require('node:test')
const { createServer } = require('node:http2')
const { once } = require('node:events')

const { Client, Agent, RetryAgent, errors, request } = require('..')

function onExpectedTeardownError (error) {
  if (error.code !== 'ECONNRESET' && error.code !== 'ERR_HTTP2_STREAM_ERROR') {
    throw error
  }
}

function trackServerResources (server) {
  const sessions = new Set()
  const timers = new Set()

  server.on('error', onExpectedTeardownError)
  server.on('session', (session) => {
    session.on('error', onExpectedTeardownError)
    session.socket?.on('error', onExpectedTeardownError)
    sessions.add(session)
    session.on('close', () => sessions.delete(session))
  })
  server.on('connection', (socket) => socket.on('error', onExpectedTeardownError))

  return {
    setTimer (callback, delay) {
      const timer = setTimeout(() => {
        timers.delete(timer)
        callback()
      }, delay)
      timers.add(timer)
    },
    clearTimers () {
      for (const timer of timers) {
        clearTimeout(timer)
      }
      timers.clear()
    },
    async close () {
      for (const session of sessions) {
        session.destroy()
      }
      await new Promise((resolve, reject) => {
        server.close((error) => error ? reject(error) : resolve())
      })
    }
  }
}

test('https://github.com/nodejs/undici/issues/5087 bodyTimeout over h2 rejects with BodyTimeoutError', async (t) => {
  const plan = tspl(t, { plan: 3 })
  const server = createServer()
  const resources = trackServerResources(server)

  server.on('stream', (stream) => {
    stream.on('error', onExpectedTeardownError)
    stream.respond({ ':status': 200, 'content-type': 'text/plain' })
    resources.setTimer(() => {
      try {
        stream.end('late')
      } catch {}
    }, 500)
  })

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

  const client = new Client(`http://localhost:${server.address().port}`, {
    allowH2: true,
    useH2c: true,
    bodyTimeout: 50,
    headersTimeout: 50
  })
  t.after(async () => {
    resources.clearTimers()
    try {
      await client.destroy()
    } finally {
      await resources.close()
    }
  })

  const res = await client.request({ path: '/', method: 'GET' })

  let err = null
  try {
    await res.body.text()
  } catch (error) {
    err = error
  }

  plan.ok(err instanceof errors.BodyTimeoutError)
  plan.strictEqual(err.code, 'UND_ERR_BODY_TIMEOUT')
  plan.strictEqual(err.message, 'HTTP/2: "stream timeout after 50"')

  await plan.completed
})

test('https://github.com/nodejs/undici/issues/5087 headersTimeout over h2 rejects with HeadersTimeoutError', async (t) => {
  const plan = tspl(t, { plan: 3 })
  const server = createServer()
  const resources = trackServerResources(server)

  server.on('stream', (stream) => {
    stream.on('error', onExpectedTeardownError)
    resources.setTimer(() => {
      try {
        stream.close()
      } catch {}
    }, 500)
  })

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

  const client = new Client(`http://localhost:${server.address().port}`, {
    allowH2: true,
    useH2c: true,
    bodyTimeout: 60_000,
    headersTimeout: 50
  })
  t.after(async () => {
    resources.clearTimers()
    try {
      await client.destroy()
    } finally {
      await resources.close()
    }
  })

  let err = null
  try {
    await client.request({ path: '/', method: 'GET' })
  } catch (error) {
    err = error
  }

  plan.ok(err instanceof errors.HeadersTimeoutError)
  plan.strictEqual(err.code, 'UND_ERR_HEADERS_TIMEOUT')
  plan.strictEqual(err.message, 'HTTP/2: "headers timeout after 50"')

  await plan.completed
})

test('https://github.com/nodejs/undici/issues/5087 RetryAgent retries h2 body timeouts by default error code matching', async (t) => {
  const plan = tspl(t, { plan: 2 })
  let hits = 0
  const server = createServer()
  const resources = trackServerResources(server)

  server.on('stream', (stream) => {
    stream.on('error', onExpectedTeardownError)
    hits += 1

    stream.respond({ ':status': 200, 'content-type': 'text/plain' })

    if (hits === 1) {
      resources.setTimer(() => {
        try {
          stream.end('late')
        } catch {}
      }, 500)
      return
    }

    stream.end(`ok after ${hits} attempt(s)`)
  })

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

  const dispatcher = new RetryAgent(new Agent({
    allowH2: true,
    useH2c: true,
    bodyTimeout: 50,
    headersTimeout: 50
  }), {
    maxRetries: 3,
    minTimeout: 10,
    errorCodes: ['UND_ERR_BODY_TIMEOUT']
  })
  t.after(async () => {
    resources.clearTimers()
    try {
      await dispatcher.destroy()
    } finally {
      await resources.close()
    }
  })

  const res = await request(`http://localhost:${server.address().port}/`, {
    dispatcher,
    method: 'GET'
  })

  plan.strictEqual(await res.body.text(), 'ok after 2 attempt(s)')
  plan.strictEqual(hits, 2)

  await plan.completed
})