'use strict'
const { tspl } = require('@matteo.collina/tspl')
const { test } = require('node:test')
const { RetryHandler } = require('..')
// These tests pin down the RetryController proxy contract introduced to keep
// flow-control wired to the active connection across transparent retries/resumes.
// Each retry/resume is a separate dispatch with its own connection controller;
// the downstream handler is handed ONE stable proxy that always forwards to the
// controller of the currently active connection. See lib/handler/retry-handler.js.
const baseOpts = {
method: 'GET',
path: '/',
retryOptions: {}
}
// Stand-in for the per-dispatch RequestController of an active connection. It
// records the flow-control calls the proxy forwards to it.
function activeConnectionController () {
const calls = []
return {
calls,
paused: true,
aborted: true,
reason: new Error('boom'),
rawHeaders: ['content-length', '2'],
rawTrailers: ['x-trailer', 'value'],
pause () { calls.push('pause') },
resume () { calls.push('resume') },
abort (reason) { calls.push(['abort', reason]) }
}
}
test('controller proxy returns safe defaults and is a no-op before a connection is active', (t) => {
t = tspl(t, { plan: 6 })
const handler = new RetryHandler(baseOpts, {
dispatch: () => {},
handler: {}
})
// No dispatch has happened yet, so the proxy has no active connection to
// forward to. Reads must fall back to safe defaults instead of throwing.
const proxy = handler.controllerProxy
t.strictEqual(proxy.paused, false)
t.strictEqual(proxy.aborted, false)
t.strictEqual(proxy.reason, null)
t.strictEqual(proxy.rawHeaders, null)
t.strictEqual(proxy.rawTrailers, null)
// Methods must be inert (not throw) while there is nothing to forward to.
t.doesNotThrow(() => {
proxy.pause()
proxy.resume()
proxy.abort(new Error('ignored'))
proxy.rawHeaders = ['x', 'y']
proxy.rawTrailers = ['z', '1']
})
})
test('controller proxy reapplies a persistent pause to a replacement connection', (t) => {
t = tspl(t, { plan: 6 })
const createController = () => {
const calls = []
let paused = false
return {
calls,
pause () {
paused = true
calls.push('pause')
},
resume () {
paused = false
calls.push('resume')
},
abort () {},
get paused () { return paused },
get aborted () { return false },
get reason () { return null }
}
}
const handler = new RetryHandler(baseOpts, {
dispatch: () => {},
handler: {}
})
const first = createController()
handler.onRequestStart(first, {})
const proxy = handler.controllerProxy
proxy.pause()
t.strictEqual(proxy.paused, true)
t.deepStrictEqual(first.calls, ['pause'])
handler.headersSent = true
const second = createController()
handler.onRequestStart(second, {})
t.strictEqual(proxy.paused, true)
t.deepStrictEqual(second.calls, ['pause'])
proxy.resume()
t.strictEqual(proxy.paused, false)
t.deepStrictEqual(second.calls, ['pause', 'resume'])
})
test('controller proxy forwards reads/writes to the active connection and stays stable across callbacks', (t) => {
t = tspl(t, { plan: 11 })
let downstreamController = null
let upgradeController = null
const upgradeArgs = []
const handler = new RetryHandler(baseOpts, {
dispatch: () => {},
handler: {
onRequestStart (controller) {
downstreamController = controller
},
onRequestUpgrade (controller, statusCode, headers, socket) {
upgradeController = controller
upgradeArgs.push(statusCode, headers, socket)
}
}
})
const connection = activeConnectionController()
// onRequestStart is the first callback of a dispatch; it re-points the proxy
// at this connection's controller and hands the (stable) proxy downstream.
handler.onRequestStart(connection, {})
// The downstream handler must receive the proxy, never the raw per-connection
// controller, so flow-control survives the next resume.
t.notStrictEqual(downstreamController, connection)
// Reads forward to the active connection's controller.
t.deepStrictEqual(downstreamController.rawHeaders, ['content-length', '2'])
t.deepStrictEqual(downstreamController.rawTrailers, ['x-trailer', 'value'])
t.strictEqual(downstreamController.paused, true)
t.strictEqual(downstreamController.aborted, true)
t.strictEqual(downstreamController.reason, connection.reason)
// Writes forward to the active connection's controller too.
downstreamController.pause()
downstreamController.resume()
downstreamController.abort('stop')
t.deepStrictEqual(connection.calls, ['pause', 'resume', ['abort', 'stop']])
// Decompress (and other interceptors) rewrite rawHeaders/rawTrailers on the
// controller they were given. Those assignments must reach the active
// connection instead of throwing on a getter-only proxy.
downstreamController.rawHeaders = ['x-foo', 'bar']
downstreamController.rawTrailers = ['x-end', '1']
t.deepStrictEqual(connection.rawHeaders, ['x-foo', 'bar'])
t.deepStrictEqual(connection.rawTrailers, ['x-end', '1'])
// An upgrade on the same dispatch is forwarded through the very same proxy
// instance (not the raw controller), keeping the downstream wiring stable.
handler.onRequestUpgrade(connection, 101, { upgrade: 'websocket' }, 'SOCKET')
t.strictEqual(upgradeController, downstreamController)
t.deepStrictEqual(upgradeArgs, [101, { upgrade: 'websocket' }, 'SOCKET'])
})
test('controller proxy is forwarded downstream on a 206 whose content-range is unparseable', (t) => {
t = tspl(t, { plan: 3 })
let startController = null
const startArgs = []
const handler = new RetryHandler(baseOpts, {
dispatch: () => {},
handler: {
onResponseStart (controller, statusCode, headers) {
startController = controller
startArgs.push(statusCode, headers)
}
}
})
const connection = activeConnectionController()
handler.onRequestStart(connection, {})
// A 206 whose content-range cannot be parsed: the handler cannot set up a
// resume, so it gives up and forwards the response downstream as-is -- still
// through the proxy, not the raw per-connection controller.
handler.onResponseStart(connection, 206, { 'content-range': 'invalid' }, 'Partial Content')
t.strictEqual(startController, handler.controllerProxy)
t.strictEqual(startArgs[0], 206)
t.deepStrictEqual(startArgs[1], { 'content-range': 'invalid' })
})
test('controller proxy carries a synchronous dispatch failure to the downstream handler', (t) => {
t = tspl(t, { plan: 2 })
const dispatchError = new Error('dispatch failed synchronously')
let errController = null
let receivedErr = null
const handler = new RetryHandler(baseOpts, {
dispatch: () => { throw dispatchError },
handler: {
onResponseError (controller, err) {
errController = controller
receivedErr = err
}
}
})
const connection = activeConnectionController()
handler.onRequestStart(connection, {})
// retry() re-dispatches; when that dispatch throws synchronously the error is
// surfaced to the downstream handler through the proxy.
handler.retry()
t.strictEqual(errController, handler.controllerProxy)
t.strictEqual(receivedErr, dispatchError)
})