'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
})