@ 261107.5
current
'use strict'

const { tspl } = require('@matteo.collina/tspl')
const { test, after } = require('node:test')
const { createServer } = require('node:http')
const { once } = require('node:events')
const { Readable } = require('node:stream')
const FakeTimers = require('@sinonjs/fake-timers')

const { RetryHandler, RetryAgent, Client } = require('..')
const { RequestHandler } = require('../lib/api/api-request')
const { kRetryHandlerDefaultRetry } = require('../lib/core/symbols')

test('Should retry status code', async t => {
  t = tspl(t, { plan: 3 })

  let counter = 0
  const chunks = []
  const server = createServer({ joinDuplicateHeaders: true })
  const dispatchOptions = {
    retryOptions: {
      retry: (err, { state, opts }, done) => {
        ++counter

        if (
          err.statusCode === 500 ||
          err.message.includes('other side closed')
        ) {
          setTimeout(done, 500)
          return
        }

        return done(err)
      }
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.on('request', (req, res) => {
    switch (counter) {
      case 0:
        req.destroy()
        return
      case 1:
        res.writeHead(500)
        res.end('failed')
        return
      case 2:
        res.writeHead(200)
        res.end('hello world!')
        return
      default:
        t.fail()
    }
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!')
          t.strictEqual(counter, 2)
        },
        onResponseError () {
          t.fail()
        }
      }
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )
  })

  await t.completed
})

test('Should account for network and response errors', async t => {
  t = tspl(t, { plan: 3 })

  let counter = 0
  const chunks = []
  const server = createServer({ joinDuplicateHeaders: true })
  const dispatchOptions = {
    retryOptions: {
      retry: (err, { state, opts }, done) => {
        counter = state.counter

        if (
          err.statusCode === 500 ||
          err.message.includes('other side closed')
        ) {
          setTimeout(done, 500)
          return
        }

        return done(err)
      }
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.on('request', (req, res) => {
    switch (counter) {
      case 0:
        req.destroy()
        return
      case 1:
        res.writeHead(500)
        res.end('failed')
        return
      case 2:
        res.writeHead(200)
        res.end('hello world!')
        return
      default:
        t.fail()
    }
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!')
          t.strictEqual(counter, 2)
        },
        onResponseError () {
          t.fail()
        }
      }
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )
  })

  await t.completed
})

test('Issue #3288 - request with body (asynciterable)', async t => {
  t = tspl(t, { plan: 4 })
  const server = createServer({ joinDuplicateHeaders: true })
  const dispatchOptions = {
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json'
    },
    body: (function * () {
      yield 'hello'
      yield 'world'
    })()
  }

  server.on('request', (req, res) => {
    res.writeHead(500, {
      'content-type': 'application/json'
    })

    res.end('{"message": "failed"}')
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          return true
        },
        onResponseData (_controller, chunk) {
          return true
        },
        onResponseEnd () {
          t.fail()
        },
        onResponseError (_controller, err) {
          t.equal(err.message, 'Request failed')
          t.equal(err.statusCode, 500)
          t.equal(err.data.count, 1)
        }
      }
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      dispatchOptions,
      handler
    )
  })

  await t.completed
})

test('Should use retry-after header for retries', async t => {
  t = tspl(t, { plan: 3 })

  let counter = 0
  const chunks = []
  const server = createServer({ joinDuplicateHeaders: true })
  let checkpoint
  const dispatchOptions = {
    method: 'PUT',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.on('request', (req, res) => {
    switch (counter) {
      case 0:
        res.writeHead(429, {
          'retry-after': 1
        })
        res.end('rate limit')
        checkpoint = Date.now()
        counter++
        return
      case 1:
        res.writeHead(200)
        res.end('hello world!')
        t.ok(Date.now() - checkpoint >= 500)
        counter++
        return
      default:
        t.fail('unexpected request')
    }
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!')
        },
        onResponseError (_controller, err) {
          t.ifError(err)
        }
      }
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      {
        method: 'PUT',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )
  })

  await t.completed
})

test('Should retry immediately when retry-after is zero', async t => {
  const clock = FakeTimers.install()
  t.after(() => clock.uninstall())

  let retries = 0
  const handler = new RetryHandler(
    {
      method: 'GET',
      retryOptions: {
        maxRetries: 1,
        minTimeout: 500
      }
    },
    {
      dispatch () {
        retries++
      },
      handler: {}
    }
  )

  handler.onResponseError(null, {
    statusCode: 429,
    code: 'UND_ERR_REQ_RETRY',
    headers: {
      'retry-after': '0'
    }
  })

  await clock.tickAsync(0)

  t.assert.strictEqual(retries, 1)
})

test('Should ignore retry-after header when retryAfter option is false', async t => {
  const clock = FakeTimers.install()
  t.after(() => clock.uninstall())

  let retries = 0
  const handler = new RetryHandler(
    {
      method: 'GET',
      retryOptions: {
        retryAfter: false,
        maxRetries: 1,
        minTimeout: 500
      }
    },
    {
      dispatch () {
        retries++
      },
      handler: {}
    }
  )

  handler.onResponseError(null, {
    statusCode: 429,
    code: 'UND_ERR_REQ_RETRY',
    headers: {
      'retry-after': '60'
    }
  })

  // Retry must be scheduled by the exponential backoff (minTimeout = 500ms),
  // not by the Retry-After header (60s)
  await clock.tickAsync(500)

  t.assert.strictEqual(retries, 1)
})

test('Should use retry-after header for retries (date)', async t => {
  t = tspl(t, { plan: 3 })

  let counter = 0
  const chunks = []
  const server = createServer({ joinDuplicateHeaders: true })
  let checkpoint
  const dispatchOptions = {
    method: 'PUT',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.on('request', (req, res) => {
    switch (counter) {
      case 0:
        checkpoint = Date.now()
        res.writeHead(429, {
          'retry-after': new Date(
            checkpoint + 2000
          ).toUTCString()
        })
        res.end('rate limit')
        counter++
        return
      case 1:
        res.writeHead(200)
        res.end('hello world!')
        t.ok(Date.now() - checkpoint >= 1000)
        counter++
        return
      default:
        t.fail('unexpected request')
    }
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!')
        },
        onResponseError (_controller, err) {
          t.ifError(err)
        }
      }
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      {
        method: 'PUT',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )
  })

  await t.completed
})

test('Should retry with defaults', async t => {
  t = tspl(t, { plan: 3 })

  let counter = 0
  const chunks = []
  const server = createServer({ joinDuplicateHeaders: true })
  const dispatchOptions = {
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.on('request', (req, res) => {
    switch (counter) {
      case 0:
        req.destroy()
        counter++
        return
      case 1:
        res.writeHead(500)
        res.end('failed')
        counter++
        return
      case 2:
        res.writeHead(200)
        res.end('hello world!')
        counter++
        return
      default:
        t.fail()
    }
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!')
        },
        onResponseError (_controller, err) {
          t.ifError(err)
        }
      }
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )
  })

  await t.completed
})

test('Should handle 206 partial content', async t => {
  t = tspl(t, { plan: 6 })

  const chunks = []
  let counter = 0

  // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47
  let x = 0
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    if (x === 0) {
      t.ok(true, 'pass')
      res.setHeader('etag', 'asd')
      res.write('abc', () => {
        res.destroy()
      })
    } else if (x === 1) {
      t.deepStrictEqual(req.headers.range, 'bytes=3-')
      res.setHeader('content-range', 'bytes 3-5/6')
      res.setHeader('etag', 'asd')
      res.statusCode = 206
      res.end('def')
    }
    x++
  })

  const dispatchOptions = {
    retryOptions: {
      retry: function (err, _, done) {
        counter++

        if (err.code && err.code === 'UND_ERR_DESTROYED') {
          return done(false)
        }

        if (err.statusCode === 206) return done(err)

        setTimeout(done, 800)
      }
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: (...args) => {
        return client.dispatch(...args)
      },
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef')
          t.strictEqual(counter, 1)
        },
        onResponseError () {
          t.fail()
        }
      }
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )

    after(async () => {
      await client.close()

      server.close()
      await once(server, 'close')
    })
  })

  await t.completed
})

test('Should handle 206 partial content - bad-etag', async t => {
  t = tspl(t, { plan: 7 })

  const chunks = []

  // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47
  let x = 0
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    if (x === 0) {
      t.ok(true, 'pass')
      res.setHeader('etag', 'asd')
      res.write('abc')
      setTimeout(() => {
        res.destroy()
      }, 1e2)
    } else if (x === 1) {
      t.deepStrictEqual(req.headers.range, 'bytes=3-')
      res.setHeader('content-range', 'bytes 3-5/6')
      res.setHeader('etag', 'erwsd')
      res.statusCode = 206
      res.end('def')
    }
    x++
  })

  const dispatchOptions = {
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(
      dispatchOptions,
      {
        dispatch: (...args) => {
          return client.dispatch(...args)
        },
        handler: {
          onRequestStart () {
            t.ok(true, 'pass')
          },
          onResponseStart (_controller, _status, _headers, _statusMessage) {
            return true
          },
          onResponseData (_controller, chunk) {
            chunks.push(chunk)
            return true
          },
          onResponseEnd () {
            t.ifError('should not complete')
          },
          onResponseError (_controller, err) {
            t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abc')
            t.strictEqual(err.code, 'UND_ERR_REQ_RETRY')
            t.strictEqual(err.message, 'ETag mismatch')
            t.deepEqual(err.data, { count: 2 })
          }
        }
      }
    )

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )

    after(async () => {
      await client.close()

      server.close()
      await once(server, 'close')
    })
  })

  await t.completed
})

test('retrying a request with a body', async t => {
  let counter = 0
  const server = createServer({ joinDuplicateHeaders: true })
  const dispatchOptions = {
    retryOptions: {
      retry: (err, { state, opts }, done) => {
        counter++

        if (
          err.statusCode === 500 ||
          err.message.includes('other side closed')
        ) {
          setTimeout(done, 500)
          return
        }

        return done(err)
      }
    },
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json'
    },
    body: JSON.stringify({ hello: 'world' })
  }

  t = tspl(t, { plan: 1 })

  server.on('request', (req, res) => {
    switch (counter) {
      case 0:
        req.destroy()
        return
      case 1:
        res.writeHead(500)
        res.end('failed')
        return
      case 2:
        res.writeHead(200)
        res.end('hello world!')
        return
      default:
        t.fail()
    }
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: new RequestHandler(dispatchOptions, (err, data) => {
        t.ifError(err)
      })
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      {
        method: 'POST',
        path: '/',
        headers: {
          'content-type': 'application/json'
        },
        body: JSON.stringify({ hello: 'world' })
      },
      handler
    )
  })

  await t.completed
})

test('retrying a request with a body (stream)', async t => {
  let counter = 0
  const server = createServer({ joinDuplicateHeaders: true })
  const dispatchOptions = {
    retryOptions: {
      retry: (err, { state, opts }, done) => {
        counter++

        if (
          err.statusCode === 500 ||
          err.message.includes('other side closed')
        ) {
          setTimeout(done, 500)
          return
        }

        return done(err)
      }
    },
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json'
    },
    body: Readable.from(Buffer.from(JSON.stringify({ hello: 'world' })))
  }

  t = tspl(t, { plan: 3 })

  server.on('request', (req, res) => {
    switch (counter) {
      case 0:
        res.writeHead(500)
        res.end('failed')
        return
      default:
        t.fail()
    }
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: new RequestHandler(dispatchOptions, (err, data) => {
        t.equal(err.statusCode, 500)
        t.equal(err.data.count, 1)
        t.equal(err.code, 'UND_ERR_REQ_RETRY')
      })
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      dispatchOptions,
      handler
    )
  })

  await t.completed
})

test('retrying a request with a body (buffer)', async t => {
  let counter = 0
  const server = createServer({ joinDuplicateHeaders: true })
  const dispatchOptions = {
    retryOptions: {
      retry: (err, { state, opts }, done) => {
        counter++

        if (
          err.statusCode === 500 ||
          err.message.includes('other side closed')
        ) {
          setTimeout(done, 500)
          return
        }

        return done(err)
      }
    },
    method: 'POST',
    path: '/',
    headers: {
      'content-type': 'application/json'
    },
    body: Buffer.from(JSON.stringify({ hello: 'world' }))
  }

  t = tspl(t, { plan: 1 })

  server.on('request', (req, res) => {
    switch (counter) {
      case 0:
        req.destroy()
        return
      case 1:
        res.writeHead(500)
        res.end('failed')
        return
      case 2:
        res.writeHead(200)
        res.end('hello world!')
        return
      default:
        t.fail()
    }
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: new RequestHandler(dispatchOptions, (err, data) => {
        t.ifError(err)
      })
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      dispatchOptions,
      handler
    )
  })

  await t.completed
})

test('should not error if request is not meant to be retried', async t => {
  t = tspl(t, { plan: 3 })

  const server = createServer({ joinDuplicateHeaders: true })
  server.on('request', (req, res) => {
    res.writeHead(400)
    res.end('Bad request')
  })

  const dispatchOptions = {
    retryOptions: {
      method: 'GET',
      path: '/',
      headers: {
        'content-type': 'application/json'
      }
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const chunks = []
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 400)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'Bad request')
        },
        onResponseError (_controller, err) {
          t.fail(err)
        }
      }
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )
  })

  await t.completed
})

test('Should be able to properly pass the minTimeout to the RetryContext when constructing a RetryCallback function', async t => {
  t = tspl(t, { plan: 2 })

  let counter = 0
  const server = createServer({ joinDuplicateHeaders: true })
  server.on('request', (req, res) => {
    switch (counter) {
      case 0:
        res.writeHead(500)
        res.end('failed')
        return
      case 1:
        res.writeHead(200)
        res.end('hello world!')
        return
      default:
        t.fail()
    }
  })

  const dispatchOptions = {
    retryOptions: {
      retry: (err, { state, opts }, done) => {
        counter++
        t.strictEqual(opts.retryOptions.minTimeout, 100)

        if (err.statusCode === 500) {
          return done()
        }

        return done(err)
      },
      minTimeout: 100
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: new RequestHandler(dispatchOptions, (err, data) => {
        t.ifError(err)
      })
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )
  })

  await t.completed
})

test('Issue#2986 - Handle custom 206', async t => {
  t = tspl(t, { plan: 6 })

  const chunks = []
  let counter = 0

  // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47
  let x = 0
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    if (x === 0) {
      t.deepStrictEqual(req.headers.range, 'bytes=0-3')
      res.setHeader('etag', 'asd')
      res.write('abc', () => {
        res.destroy()
      })
    } else if (x === 1) {
      t.deepStrictEqual(req.headers.range, 'bytes=3-')
      res.setHeader('content-range', 'bytes 3-5/6')
      res.setHeader('etag', 'asd')
      res.statusCode = 206
      res.end('def')
    }
    x++
  })

  const dispatchOptions = {
    retryOptions: {
      retry: function (err, _, done) {
        counter++

        if (err.code && err.code === 'UND_ERR_DESTROYED') {
          return done(false)
        }

        if (err.statusCode === 206) return done(err)

        setTimeout(done, 800)
      }
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: (...args) => {
        return client.dispatch(...args)
      },
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef')
          t.strictEqual(counter, 1)
        },
        onResponseError () {
          t.fail()
        }
      }
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json',
          Range: 'bytes=0-3'
        }
      },
      handler
    )

    after(async () => {
      await client.close()

      server.close()
      await once(server, 'close')
    })
  })

  await t.completed
})

test('Should resume 206 response with unknown complete length Content-Range', async t => {
  t = tspl(t, { plan: 6 })

  const chunks = []
  let counter = 0

  let x = 0
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    if (x === 0) {
      t.deepStrictEqual(req.headers.range, 'bytes=0-3')
      res.setHeader('etag', 'asd')
      res.write('abc', () => {
        res.destroy()
      })
    } else if (x === 1) {
      t.deepStrictEqual(req.headers.range, 'bytes=3-')
      res.setHeader('content-range', 'bytes 3-5/*')
      res.setHeader('etag', 'asd')
      res.statusCode = 206
      res.end('def')
    }
    x++
  })

  const dispatchOptions = {
    retryOptions: {
      retry: function (err, _, done) {
        counter++

        if (err.code && err.code === 'UND_ERR_DESTROYED') {
          return done(false)
        }

        if (err.statusCode === 206) return done(err)

        setTimeout(done, 800)
      }
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: (...args) => {
        return client.dispatch(...args)
      },
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef')
          t.strictEqual(counter, 1)
        },
        onResponseError () {
          t.fail()
        }
      }
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json',
          Range: 'bytes=0-3'
        }
      },
      handler
    )

    after(async () => {
      await client.close()

      server.close()
      await once(server, 'close')
    })
  })

  await t.completed
})

test('Issue#3128 - Support if-match', async t => {
  t = tspl(t, { plan: 7 })

  const chunks = []
  let counter = 0

  // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47
  let x = 0
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    if (x === 0) {
      t.deepStrictEqual(req.headers.range, 'bytes=0-3')
      res.setHeader('etag', 'asd')
      res.write('abc')
      setTimeout(() => {
        res.destroy()
      }, 1e2)
    } else if (x === 1) {
      t.deepStrictEqual(req.headers.range, 'bytes=3-')
      t.deepStrictEqual(req.headers['if-match'], 'asd')

      res.setHeader('content-range', 'bytes 3-5/6')
      res.setHeader('etag', 'asd')
      res.statusCode = 206
      res.end('def')
    }
    x++
  })

  const dispatchOptions = {
    retryOptions: {
      retry: function (err, _, done) {
        counter++

        if (err.code && err.code === 'UND_ERR_DESTROYED') {
          return done(false)
        }

        if (err.statusCode === 206) return done(err)

        setTimeout(done, 800)
      }
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: (...args) => {
        return client.dispatch(...args)
      },
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef')
          t.strictEqual(counter, 1)
        },
        onResponseError () {
          t.fail()
        }
      }
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json',
          Range: 'bytes=0-3'
        }
      },
      handler
    )

    after(async () => {
      await client.close()

      server.close()
      await once(server, 'close')
    })
  })

  await t.completed
})

test('Issue#3128 - Should ignore weak etags', async t => {
  t = tspl(t, { plan: 7 })

  const chunks = []
  let counter = 0

  // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47
  let x = 0
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    if (x === 0) {
      t.deepStrictEqual(req.headers.range, 'bytes=0-3')
      res.setHeader('etag', 'W/asd')
      res.write('abc')
      setTimeout(() => {
        res.destroy()
      }, 1e2)
    } else if (x === 1) {
      t.deepStrictEqual(req.headers.range, 'bytes=3-')
      t.equal(req.headers['if-match'], undefined)

      res.setHeader('content-range', 'bytes 3-5/6')
      res.setHeader('etag', 'W/asd')
      res.statusCode = 206
      res.end('def')
    }
    x++
  })

  const dispatchOptions = {
    retryOptions: {
      retry: function (err, _, done) {
        counter++

        if (err.code && err.code === 'UND_ERR_DESTROYED') {
          return done(false)
        }

        if (err.statusCode === 206) return done(err)

        setTimeout(done, 800)
      }
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: (...args) => {
        return client.dispatch(...args)
      },
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef')
          t.strictEqual(counter, 1)
        },
        onResponseError () {
          t.fail()
        }
      }
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json',
          Range: 'bytes=0-3'
        }
      },
      handler
    )

    after(async () => {
      await client.close()

      server.close()
      await once(server, 'close')
    })
  })

  await t.completed
})

test('Weak etags are ignored on range-requests', async t => {
  t = tspl(t, { plan: 7 })

  const chunks = []
  let counter = 0

  // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47
  let x = 0
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    if (x === 0) {
      t.deepStrictEqual(req.headers.range, 'bytes=0-3')
      res.setHeader('etag', 'W/asd')
      res.write('abc')
      setTimeout(() => {
        res.destroy()
      }, 1e2)
    } else if (x === 1) {
      t.deepStrictEqual(req.headers.range, 'bytes=3-')
      t.equal(req.headers['if-match'], undefined)

      res.setHeader('content-range', 'bytes 3-5/6')
      res.setHeader('etag', 'W/efg')
      res.statusCode = 206
      res.end('def')
    }
    x++
  })

  const dispatchOptions = {
    retryOptions: {
      retry: function (err, _, done) {
        counter++

        if (err.code && err.code === 'UND_ERR_DESTROYED') {
          return done(false)
        }

        if (err.statusCode === 206) return done(err)

        setTimeout(done, 800)
      }
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: (...args) => {
        return client.dispatch(...args)
      },
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abcdef')
          t.strictEqual(counter, 1)
        },
        onResponseError () {
          t.fail()
        }
      }
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json',
          Range: 'bytes=0-3'
        }
      },
      handler
    )

    after(async () => {
      await client.close()

      server.close()
      await once(server, 'close')
    })
  })

  await t.completed
})

test('Should throw RequestRetryError when Content-Range mismatch', async t => {
  t = tspl(t, { plan: 8 })

  const chunks = []

  // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47
  let x = 0
  const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
    if (x === 0) {
      t.ok(true, 'pass')
      res.setHeader('etag', 'asd')
      res.write('abc')
      setTimeout(() => {
        res.destroy()
      }, 1e2)
    } else if (x === 1) {
      t.deepStrictEqual(req.headers.range, 'bytes=3-')
      res.setHeader('content-range', 'bytes bad') // intentionally bad to trigger error
      res.setHeader('etag', 'asd')
      res.statusCode = 206
      res.end('def')
    }
    x++
  })

  const dispatchOptions = {
    retryOptions: {
      retry: function (err, _, done) {
        if (err.code && err.code === 'UND_ERR_DESTROYED') {
          return done(false)
        }

        if (err.statusCode === 206) return done(err)

        setTimeout(done, 800)
      }
    },
    method: 'GET',
    path: '/',
    headers: {
      'content-type': 'application/json'
    }
  }

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: (...args) => {
        return client.dispatch(...args)
      },
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.ifError('should not complete')
        },
        onResponseError (_controller, err) {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'abc')
          t.strictEqual(err.code, 'UND_ERR_REQ_RETRY')
          t.strictEqual(err.message, 'Content-Range mismatch')
          t.deepEqual(err.data, { count: 2 })
        }
      }
    })

    client.dispatch(
      {
        method: 'GET',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )

    after(async () => {
      await client.close()

      server.close()
      await once(server, 'close')
    })
  })

  await t.completed
})

test('Should use retry-after header for retries (date) but date format is wrong', async t => {
  t = tspl(t, { plan: 3 })

  let counter = 0
  const chunks = []
  const server = createServer({ joinDuplicateHeaders: true })
  let checkpoint
  const dispatchOptions = {
    method: 'PUT',
    path: '/',
    headers: {
      'content-type': 'application/json'
    },
    retryOptions: {
      minTimeout: 1000
    }
  }
  const minRetryDelay = dispatchOptions.retryOptions.minTimeout

  server.on('request', (req, res) => {
    switch (counter) {
      case 0: {
        checkpoint = process.hrtime.bigint()
        res.writeHead(429, {
          'retry-after': 'this is not a date'
        })
        res.end('rate limit')
        counter++
        return
      }
      case 1: {
        res.writeHead(200)
        res.end('hello world!')
        const elapsedMs = Number(process.hrtime.bigint() - checkpoint) / 1e6
        t.ok(elapsedMs >= minRetryDelay - 100)
        counter++
        return
      }
      default:
        t.fail('unexpected request')
    }
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const handler = new RetryHandler(dispatchOptions, {
      dispatch: client.dispatch.bind(client),
      handler: {
        onRequestStart () {
          t.ok(true, 'pass')
        },
        onResponseStart (_controller, status, _headers, _statusMessage) {
          t.strictEqual(status, 200)
          return true
        },
        onResponseData (_controller, chunk) {
          chunks.push(chunk)
          return true
        },
        onResponseEnd () {
          t.strictEqual(Buffer.concat(chunks).toString('utf-8'), 'hello world!')
        },
        onResponseError (_controller, err) {
          t.ifError(err)
        }
      }
    })

    after(async () => {
      await client.close()
      server.close()

      await once(server, 'close')
    })

    client.dispatch(
      {
        method: 'PUT',
        path: '/',
        headers: {
          'content-type': 'application/json'
        }
      },
      handler
    )
  })

  await t.completed
})

test('Should abort immediately when aborted during the retry backoff', async t => {
  t = tspl(t, { plan: 3 })

  let hits = 0
  const ac = new AbortController()
  const startedAt = Date.now()
  const server = createServer({ joinDuplicateHeaders: true })

  server.on('request', (req, res) => {
    hits++
    res.writeHead(500)
    res.end('failed')
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const agent = new RetryAgent(client, {
      minTimeout: 10_000,
      maxRetries: 5,
      retry: (err, ctx, done) => {
        // The default policy has just scheduled a 10s backoff; abort while it
        // is pending (setImmediate so the handler captures the timer first).
        setImmediate(() => ac.abort())
        return RetryHandler[kRetryHandlerDefaultRetry](err, ctx, done)
      }
    })

    after(async () => {
      await agent.close()
      server.close()

      await once(server, 'close')
    })

    agent.request({
      method: 'GET',
      path: '/',
      signal: ac.signal
    }).then(() => {
      t.fail('request should have been aborted')
    }, (err) => {
      t.strictEqual(err.name, 'AbortError', 'rejects with the abort reason')
      t.ok(Date.now() - startedAt < 5_000, 'rejects long before the 10s backoff elapses')
      t.strictEqual(hits, 1, 'no retry is dispatched after the abort')
    })
  })

  await t.completed
})

test('Should ignore a late retry decision after an abort during the backoff', async t => {
  t = tspl(t, { plan: 4 })

  let hits = 0
  let policyCalls = 0
  const ac = new AbortController()
  const startedAt = Date.now()
  const server = createServer({ joinDuplicateHeaders: true })

  server.on('request', (req, res) => {
    hits++
    res.writeHead(500)
    res.end('failed')
  })

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    const agent = new RetryAgent(client, {
      maxRetries: 5,
      retry: (err, ctx, done) => {
        policyCalls++
        if (policyCalls > 1) {
          done(err)
          return
        }
        // A custom policy with its own 3s backoff that does not return the
        // timer: the abort must still terminate the request immediately and
        // the late decision must be ignored.
        setTimeout(() => done(null), 3_000)
        setImmediate(() => ac.abort())
      }
    })

    after(async () => {
      await agent.close()
      server.close()

      await once(server, 'close')
    })

    agent.request({
      method: 'GET',
      path: '/',
      signal: ac.signal
    }).then(() => {
      t.fail('request should have been aborted')
    }, async (err) => {
      t.strictEqual(err.name, 'AbortError', 'rejects with the abort reason')
      t.ok(Date.now() - startedAt < 1_500, 'rejects long before the 3s custom backoff')
      t.strictEqual(hits, 1, 'no retry is dispatched after the abort')

      // The custom policy's timer fires at 3s; its late decision is moot.
      await new Promise(resolve => setTimeout(resolve, 3_500))
      t.strictEqual(hits, 1, 'late retry decision is ignored')
    })
  })

  await t.completed
})