@nxtedition/nxt-undici 7.4.0 → 7.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +47 -8
- package/lib/index.js +14 -2
- package/lib/interceptor/cache.js +93 -43
- package/lib/interceptor/dns.js +114 -7
- package/lib/interceptor/log.js +184 -9
- package/lib/interceptor/pressure.js +103 -10
- package/lib/interceptor/proxy.js +48 -15
- package/lib/interceptor/redirect.js +48 -5
- package/lib/interceptor/request-body-factory.js +62 -9
- package/lib/interceptor/response-error.js +19 -2
- package/lib/interceptor/response-retry.js +273 -21
- package/lib/interceptor/response-verify.js +10 -0
- package/lib/request.js +5 -1
- package/lib/sqlite-cache-store.js +86 -9
- package/lib/utils.js +38 -15
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
|
-
import { DecoratorHandler, isDisturbed, parseURL, parseHeaders } from '../utils.js'
|
|
2
|
+
import { DecoratorHandler, isDisturbed, parseURL, parseHeaders, buildURL } from '../utils.js'
|
|
3
3
|
|
|
4
4
|
const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
|
|
5
5
|
|
|
@@ -54,6 +54,19 @@ class Handler extends DecoratorHandler {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
onHeaders(statusCode, headers, resume) {
|
|
57
|
+
// Informational (1xx, e.g. 100 Continue / 103 Early Hints) responses are
|
|
58
|
+
// interim — not final and not redirects — so forward them through without
|
|
59
|
+
// marking headersSent or counting a redirect hop. Otherwise the
|
|
60
|
+
// `assert(!headersSent)` below trips (turning a good response into an
|
|
61
|
+
// onError) when the real final response arrives after an interim one. This
|
|
62
|
+
// matches the 1xx passthrough already in response-retry and RequestHandler;
|
|
63
|
+
// it is exercised when an inner dispatcher forwards a 1xx (raw undici
|
|
64
|
+
// strips them, but composed/mock dispatchers and future early-hints support
|
|
65
|
+
// do not).
|
|
66
|
+
if (statusCode < 200) {
|
|
67
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
if (redirectableStatusCodes.indexOf(statusCode) === -1) {
|
|
58
71
|
assert(!this.#headersSent)
|
|
59
72
|
this.#headersSent = true
|
|
@@ -92,9 +105,15 @@ class Handler extends DecoratorHandler {
|
|
|
92
105
|
}
|
|
93
106
|
}
|
|
94
107
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
// Build the base URL by concatenating origin + path via buildURL rather
|
|
109
|
+
// than `new URL(path, origin)`: the latter is unsafe when `path` is
|
|
110
|
+
// protocol-relative (e.g. `//evil-host/x`, reachable via a request URL like
|
|
111
|
+
// `https://good.com//evil-host/x`). WHATWG URL would then treat the path's
|
|
112
|
+
// leading host as the authority and discard the good origin, so a *relative*
|
|
113
|
+
// Location would resolve against the attacker-controlled host — an SSRF /
|
|
114
|
+
// request-misrouting pivot. See buildURL in ../utils.js.
|
|
115
|
+
const base = this.#opts.origin && buildURL(this.#opts.origin, this.#opts.path)
|
|
116
|
+
const { origin, pathname, search } = parseURL(new URL(this.#location, base))
|
|
98
117
|
const path = search ? `${pathname}${search}` : pathname
|
|
99
118
|
|
|
100
119
|
// Remove headers referring to the original URL.
|
|
@@ -105,7 +124,7 @@ class Handler extends DecoratorHandler {
|
|
|
105
124
|
headers: cleanRequestHeaders(
|
|
106
125
|
this.#opts.headers,
|
|
107
126
|
statusCode === 303,
|
|
108
|
-
this.#opts.origin
|
|
127
|
+
!isSameOrigin(this.#opts.origin, origin),
|
|
109
128
|
),
|
|
110
129
|
path,
|
|
111
130
|
origin,
|
|
@@ -178,6 +197,27 @@ class Handler extends DecoratorHandler {
|
|
|
178
197
|
}
|
|
179
198
|
}
|
|
180
199
|
|
|
200
|
+
// `target` is always a WHATWG-normalized origin (it comes off a parsed URL:
|
|
201
|
+
// lowercased host, default port elided, no path), while `optsOrigin` is
|
|
202
|
+
// caller-provided and may not be (trailing slash, explicit `:80`/`:443`,
|
|
203
|
+
// uppercase host — defaultLookup in index.js even produces `http://host:80`
|
|
204
|
+
// for object-form origins). A raw string compare misclassifies such
|
|
205
|
+
// same-origin redirects as cross-origin and strips authorization/cookie,
|
|
206
|
+
// breaking authenticated redirect flows with a confusing 401. Normalize
|
|
207
|
+
// through `new URL` before comparing; if optsOrigin is not parseable, keep
|
|
208
|
+
// the raw-compare result (already false here), which fails toward stripping —
|
|
209
|
+
// the safe direction.
|
|
210
|
+
function isSameOrigin(optsOrigin, target) {
|
|
211
|
+
if (optsOrigin === target) {
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
return new URL(optsOrigin).origin === target
|
|
216
|
+
} catch {
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
181
221
|
// https://tools.ietf.org/html/rfc7231#section-6.4.4
|
|
182
222
|
function shouldRemoveHeader(header, removeContent, unknownOrigin) {
|
|
183
223
|
return (
|
|
@@ -186,6 +226,9 @@ function shouldRemoveHeader(header, removeContent, unknownOrigin) {
|
|
|
186
226
|
(unknownOrigin &&
|
|
187
227
|
header.length === 13 &&
|
|
188
228
|
header.toString().toLowerCase() === 'authorization') ||
|
|
229
|
+
(unknownOrigin &&
|
|
230
|
+
header.length === 19 &&
|
|
231
|
+
header.toString().toLowerCase() === 'proxy-authorization') ||
|
|
189
232
|
(unknownOrigin && header.length === 6 && header.toString().toLowerCase() === 'cookie')
|
|
190
233
|
)
|
|
191
234
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Readable, finished } from 'node:stream'
|
|
2
|
-
import { isStream } from '../utils.js'
|
|
2
|
+
import { DecoratorHandler, isStream } from '../utils.js'
|
|
3
3
|
|
|
4
4
|
function noop() {}
|
|
5
5
|
|
|
@@ -15,11 +15,26 @@ class FactoryStream extends Readable {
|
|
|
15
15
|
|
|
16
16
|
_construct(callback) {
|
|
17
17
|
this.#ac = new AbortController()
|
|
18
|
+
// Note: #ac is intentionally kept after the factory settles, so that a
|
|
19
|
+
// destroy with an error can still abort the factory's signal (see
|
|
20
|
+
// _destroy) — e.g. the request failing before undici started writing
|
|
21
|
+
// the body.
|
|
18
22
|
Promise.resolve(this.#factory({ signal: this.#ac.signal })).then(
|
|
19
23
|
(body) => {
|
|
20
|
-
this.#ac = null
|
|
21
24
|
try {
|
|
22
|
-
|
|
25
|
+
// Normalize binary bodies to Buffer (zero-copy: reinterpret the same
|
|
26
|
+
// memory). Without this a TypedArray/DataView falls through to
|
|
27
|
+
// Readable.from(), which iterates e.g. a Uint8Array element-wise and
|
|
28
|
+
// push(number) then throws an uncaught ERR_INVALID_ARG_TYPE inside
|
|
29
|
+
// the 'data' emit. Mirrors utils.js isBuffer() treating Uint8Array
|
|
30
|
+
// as a buffer, extended to all ArrayBuffer views.
|
|
31
|
+
if (ArrayBuffer.isView(body) && !Buffer.isBuffer(body)) {
|
|
32
|
+
body = Buffer.from(body.buffer, body.byteOffset, body.byteLength)
|
|
33
|
+
} else if (body instanceof ArrayBuffer) {
|
|
34
|
+
body = Buffer.from(body)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof body === 'string' || Buffer.isBuffer(body)) {
|
|
23
38
|
this.push(body)
|
|
24
39
|
this.push(null)
|
|
25
40
|
} else if (isStream(body)) {
|
|
@@ -31,8 +46,10 @@ class FactoryStream extends Readable {
|
|
|
31
46
|
if (this.#body != null) {
|
|
32
47
|
this.#body
|
|
33
48
|
.on('data', (data) => {
|
|
49
|
+
// `?.`: a 'data' event can still be queued when _destroy() has
|
|
50
|
+
// already nulled #body, and `_read` guards the same way.
|
|
34
51
|
if (!this.push(data)) {
|
|
35
|
-
this.#body
|
|
52
|
+
this.#body?.pause()
|
|
36
53
|
}
|
|
37
54
|
})
|
|
38
55
|
.on('end', () => {
|
|
@@ -65,7 +82,13 @@ class FactoryStream extends Readable {
|
|
|
65
82
|
|
|
66
83
|
_destroy(err, callback) {
|
|
67
84
|
if (this.#ac) {
|
|
68
|
-
|
|
85
|
+
// Abort the factory's signal on any error, and on a premature destroy
|
|
86
|
+
// (destroyed before 'end'), so the factory can cancel whatever it is
|
|
87
|
+
// producing. A clean destroy after a fully consumed body (autoDestroy
|
|
88
|
+
// after 'end') must not abort — the factory finished normally.
|
|
89
|
+
if (err || !this.readableEnded) {
|
|
90
|
+
this.#ac.abort(err)
|
|
91
|
+
}
|
|
69
92
|
this.#ac = null
|
|
70
93
|
}
|
|
71
94
|
|
|
@@ -78,7 +101,37 @@ class FactoryStream extends Readable {
|
|
|
78
101
|
}
|
|
79
102
|
}
|
|
80
103
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
104
|
+
class Handler extends DecoratorHandler {
|
|
105
|
+
#body
|
|
106
|
+
|
|
107
|
+
constructor(handler, body) {
|
|
108
|
+
super(handler)
|
|
109
|
+
this.#body = body
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onError(err) {
|
|
113
|
+
// Undici only destroys a request body once it has started writing it. If
|
|
114
|
+
// the dispatch fails before that (connect error/timeout, DNS failure,
|
|
115
|
+
// abort while queued, sync throw from an inner interceptor), nobody else
|
|
116
|
+
// owns the FactoryStream — but the factory has already run on nextTick
|
|
117
|
+
// (side effects, e.g. an open fd from fs.createReadStream), so destroy it
|
|
118
|
+
// here. destroy() is idempotent: if undici already consumed or destroyed
|
|
119
|
+
// the stream this is a no-op.
|
|
120
|
+
this.#body.destroy(err)
|
|
121
|
+
super.onError(err)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default () => (dispatch) => (opts, handler) => {
|
|
126
|
+
if (typeof opts.body !== 'function') {
|
|
127
|
+
return dispatch(opts, handler)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const body = new FactoryStream(opts.body).on('error', noop)
|
|
131
|
+
try {
|
|
132
|
+
return dispatch({ ...opts, body }, new Handler(handler, body))
|
|
133
|
+
} catch (err) {
|
|
134
|
+
body.destroy(err)
|
|
135
|
+
throw err
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -10,6 +10,7 @@ class Handler extends DecoratorHandler {
|
|
|
10
10
|
#body = ''
|
|
11
11
|
#bodySize = 0
|
|
12
12
|
#opts
|
|
13
|
+
#reason = null
|
|
13
14
|
|
|
14
15
|
constructor(opts, { handler }) {
|
|
15
16
|
super(handler)
|
|
@@ -23,8 +24,14 @@ class Handler extends DecoratorHandler {
|
|
|
23
24
|
this.#headers = null
|
|
24
25
|
this.#body = ''
|
|
25
26
|
this.#bodySize = 0
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
this.#reason = null
|
|
28
|
+
|
|
29
|
+
super.onConnect((reason) => {
|
|
30
|
+
// Remember the abort reason so onError can recognize it. The reason is
|
|
31
|
+
// owned by the caller (e.g. signal.reason) and must not be decorated.
|
|
32
|
+
this.#reason = reason ?? null
|
|
33
|
+
abort(reason)
|
|
34
|
+
})
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
onHeaders(statusCode, headers, resume) {
|
|
@@ -81,6 +88,16 @@ class Handler extends DecoratorHandler {
|
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
onError(err) {
|
|
91
|
+
// An abort reason is owned by the caller and may be shared by every
|
|
92
|
+
// request in flight on the same signal — decorating it in place would
|
|
93
|
+
// permanently mutate the caller's object and leak one request's req/res
|
|
94
|
+
// onto another request's rejection (last writer wins). Callers also rely
|
|
95
|
+
// on receiving the exact reason object, so pass it through untouched.
|
|
96
|
+
if (err != null && (err === this.#reason || err === this.#opts.signal?.reason)) {
|
|
97
|
+
super.onError(err)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
super.onError(
|
|
85
102
|
decorateError(err, this.#opts, {
|
|
86
103
|
statusCode: this.#statusCode || undefined,
|
|
@@ -1,9 +1,96 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
2
|
import tp from 'node:timers/promises'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DecoratorHandler,
|
|
5
|
+
isDisturbed,
|
|
6
|
+
decorateError,
|
|
7
|
+
parseContentRange,
|
|
8
|
+
parseHeaders,
|
|
9
|
+
} from '../utils.js'
|
|
10
|
+
import { RequestAbortedError } from '../errors.js'
|
|
11
|
+
|
|
12
|
+
// Maximum number of >= 400 response body bytes buffered for replay in case
|
|
13
|
+
// the response is not retried. Larger bodies are passed straight through and
|
|
14
|
+
// the response is not status-code retried.
|
|
15
|
+
const MAX_ERROR_BODY_SIZE = 256 * 1024
|
|
4
16
|
|
|
5
17
|
function noop() {}
|
|
6
18
|
|
|
19
|
+
// Subscribe `onAbort` to an EventEmitter-style OR EventTarget-style signal and
|
|
20
|
+
// return an unsubscribe function; return null when it cannot be done safely.
|
|
21
|
+
//
|
|
22
|
+
// The caller (both sleep() and #backoff) must be able to unsubscribe once the
|
|
23
|
+
// wait settles, so we require a MATCHING subscribe/unsubscribe PAIR up front:
|
|
24
|
+
// subscribing with `on` but no `removeListener`/`off`, or `addEventListener`
|
|
25
|
+
// but no `removeEventListener`, would crash later when we try to remove the
|
|
26
|
+
// listener. request() validates signals up front (lib/request.js throws
|
|
27
|
+
// InvalidArgumentError for anything without .on/.addEventListener), so garbage
|
|
28
|
+
// only reaches here through a raw compose()/dispatch() caller — for that case
|
|
29
|
+
// return null so the caller degrades to a plain timer instead of crashing.
|
|
30
|
+
// A throwing subscribe is treated the same way.
|
|
31
|
+
function subscribeAbort(signal, onAbort) {
|
|
32
|
+
const isEventTarget =
|
|
33
|
+
typeof signal.addEventListener === 'function' &&
|
|
34
|
+
typeof signal.removeEventListener === 'function'
|
|
35
|
+
const removeEmitterListener =
|
|
36
|
+
typeof signal.removeListener === 'function' ? signal.removeListener : signal.off
|
|
37
|
+
const isEmitter = typeof signal.on === 'function' && typeof removeEmitterListener === 'function'
|
|
38
|
+
|
|
39
|
+
if (!isEventTarget && !isEmitter) {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (isEventTarget) {
|
|
45
|
+
signal.addEventListener('abort', onAbort)
|
|
46
|
+
return () => signal.removeEventListener('abort', onAbort)
|
|
47
|
+
}
|
|
48
|
+
signal.on('abort', onAbort)
|
|
49
|
+
return () => removeEmitterListener.call(signal, 'abort', onAbort)
|
|
50
|
+
} catch {
|
|
51
|
+
// A throwing subscribe leaves nothing to unsubscribe.
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// timers/promises.setTimeout only accepts a real AbortSignal and throws
|
|
57
|
+
// ERR_INVALID_ARG_TYPE for anything else — but the library also accepts
|
|
58
|
+
// EventEmitter-style abort signals (see RequestHandler in lib/request.js).
|
|
59
|
+
// For those, race the backoff timer against the 'abort' event instead of
|
|
60
|
+
// passing the signal through. When no complete subscribe/unsubscribe pair is
|
|
61
|
+
// available (a raw dispatch() caller passing a bare object), fall back to a
|
|
62
|
+
// plain timer rather than crash.
|
|
63
|
+
function sleep(delay, signal) {
|
|
64
|
+
if (signal == null || signal instanceof AbortSignal) {
|
|
65
|
+
return tp.setTimeout(delay, true, { signal: signal ?? undefined })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (signal.aborted) {
|
|
69
|
+
return Promise.reject(signal.reason ?? new RequestAbortedError())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
let removeAbortListener = noop
|
|
74
|
+
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
removeAbortListener()
|
|
77
|
+
resolve(true)
|
|
78
|
+
}, delay)
|
|
79
|
+
|
|
80
|
+
const onAbort = () => {
|
|
81
|
+
clearTimeout(timer)
|
|
82
|
+
removeAbortListener()
|
|
83
|
+
reject(signal.reason ?? new RequestAbortedError())
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Decide the branch up front by verifying a matching add/remove pair;
|
|
87
|
+
// without one there is nothing to safely race against, so the already
|
|
88
|
+
// running plain timer completes the sleep. A throwing subscribe leaves
|
|
89
|
+
// removeAbortListener as noop, and the timer still resolves.
|
|
90
|
+
removeAbortListener = subscribeAbort(signal, onAbort) ?? noop
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
7
94
|
class Handler extends DecoratorHandler {
|
|
8
95
|
#dispatch
|
|
9
96
|
#opts
|
|
@@ -23,6 +110,7 @@ class Handler extends DecoratorHandler {
|
|
|
23
110
|
#aborted = false
|
|
24
111
|
#reason
|
|
25
112
|
#resume
|
|
113
|
+
#retryAbortController = null
|
|
26
114
|
|
|
27
115
|
#pos
|
|
28
116
|
#end
|
|
@@ -60,11 +148,17 @@ class Handler extends DecoratorHandler {
|
|
|
60
148
|
super.onConnect((reason) => {
|
|
61
149
|
if (!this.#aborted) {
|
|
62
150
|
this.#aborted = true
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
151
|
+
// Always remember the reason: a downstream abort can land during the
|
|
152
|
+
// backoff wait between attempts, when #abort still points at the
|
|
153
|
+
// finished attempt's abort which undici has made a no-op. Without
|
|
154
|
+
// the recorded reason there is nothing to deliver once the retry
|
|
155
|
+
// machinery observes #aborted. Mirrors redirect.js.
|
|
156
|
+
this.#reason = reason
|
|
157
|
+
this.#abort?.(reason)
|
|
158
|
+
// Wake a pending backoff wait so the terminal onError is delivered
|
|
159
|
+
// promptly and the ref'd retry timer (up to 60s for retry-after)
|
|
160
|
+
// does not hold the event loop.
|
|
161
|
+
this.#retryAbortController?.abort(reason)
|
|
68
162
|
}
|
|
69
163
|
})
|
|
70
164
|
}
|
|
@@ -121,18 +215,33 @@ class Handler extends DecoratorHandler {
|
|
|
121
215
|
this.#end = contentLength
|
|
122
216
|
this.#etag = headers.etag
|
|
123
217
|
} else if (statusCode >= 400) {
|
|
218
|
+
if (
|
|
219
|
+
contentLength != null &&
|
|
220
|
+
contentLength > MAX_ERROR_BODY_SIZE &&
|
|
221
|
+
this.#opts.method !== 'HEAD'
|
|
222
|
+
) {
|
|
223
|
+
// The error body is too large to buffer for a replay if the retry
|
|
224
|
+
// is declined. Pass it straight through instead of buffering —
|
|
225
|
+
// such a response is simply not status-code retried.
|
|
226
|
+
this.#headersSent = true
|
|
227
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
228
|
+
}
|
|
229
|
+
|
|
124
230
|
this.#body = []
|
|
125
231
|
this.#bodySize = 0
|
|
232
|
+
this.#resume = resume
|
|
126
233
|
return true
|
|
127
234
|
} else {
|
|
128
235
|
this.#headersSent = true
|
|
129
236
|
return super.onHeaders(statusCode, headers, resume)
|
|
130
237
|
}
|
|
131
238
|
|
|
239
|
+
// A duplicated etag response header arrives as an array, which has no
|
|
240
|
+
// startsWith and cannot be used for resumption — treat it as absent.
|
|
132
241
|
// Weak etags are not useful for comparison nor cache
|
|
133
242
|
// for instance not safe to assume if the response is byte-per-byte
|
|
134
243
|
// equal
|
|
135
|
-
if (this.#etag != null && this.#etag.startsWith('W/')) {
|
|
244
|
+
if (this.#etag != null && (typeof this.#etag !== 'string' || this.#etag.startsWith('W/'))) {
|
|
136
245
|
this.#etag = null
|
|
137
246
|
}
|
|
138
247
|
|
|
@@ -158,6 +267,17 @@ class Handler extends DecoratorHandler {
|
|
|
158
267
|
// permits ignoring Range; if-match still guards against changed
|
|
159
268
|
// content via a 412.) Without this, a legal full 200 retry was
|
|
160
269
|
// rejected with "Response retry failed".
|
|
270
|
+
//
|
|
271
|
+
// The server restarted the response from scratch, so the previous
|
|
272
|
+
// attempt's resume metadata no longer describes what we're receiving:
|
|
273
|
+
// refresh #end/#etag from THIS response's headers, otherwise a second
|
|
274
|
+
// failure would resume against the stale content-length/etag.
|
|
275
|
+
const contentLength = headers['content-length'] ? Number(headers['content-length']) : null
|
|
276
|
+
this.#end = Number.isFinite(contentLength) ? contentLength : null
|
|
277
|
+
// Same guard as the first-response path: only strong (non-weak),
|
|
278
|
+
// scalar etags are safe to use for resume validation.
|
|
279
|
+
this.#etag =
|
|
280
|
+
typeof headers.etag === 'string' && !headers.etag.startsWith('W/') ? headers.etag : null
|
|
161
281
|
this.#resume = resume
|
|
162
282
|
return true
|
|
163
283
|
}
|
|
@@ -183,7 +303,17 @@ class Handler extends DecoratorHandler {
|
|
|
183
303
|
// TODO (fix): What if we were paused before the error?
|
|
184
304
|
return true
|
|
185
305
|
} else {
|
|
186
|
-
|
|
306
|
+
// A resume attempt landed on an unexpected status (e.g. a 503 while
|
|
307
|
+
// resuming). #retryError describes the PREVIOUS failure — surfacing it
|
|
308
|
+
// as-is would report a stale error that says nothing about what just
|
|
309
|
+
// happened. Report the current status and keep the prior failure as
|
|
310
|
+
// the cause.
|
|
311
|
+
const err = new Error(
|
|
312
|
+
`Response retry failed with status code ${statusCode}`,
|
|
313
|
+
this.#retryError != null ? { cause: this.#retryError } : undefined,
|
|
314
|
+
)
|
|
315
|
+
err.statusCode = statusCode
|
|
316
|
+
this.#maybeError(err)
|
|
187
317
|
return false
|
|
188
318
|
}
|
|
189
319
|
}
|
|
@@ -193,16 +323,32 @@ class Handler extends DecoratorHandler {
|
|
|
193
323
|
this.#pos += chunk.byteLength
|
|
194
324
|
}
|
|
195
325
|
|
|
196
|
-
if (this.#statusCode < 400) {
|
|
326
|
+
if (this.#statusCode < 400 || (this.#headersSent && !this.#errorSent)) {
|
|
197
327
|
return super.onData(chunk)
|
|
198
328
|
}
|
|
199
329
|
|
|
200
330
|
if (this.#body) {
|
|
201
331
|
this.#body.push(chunk)
|
|
202
332
|
this.#bodySize += chunk.byteLength
|
|
203
|
-
if (this.#bodySize >
|
|
333
|
+
if (this.#bodySize > MAX_ERROR_BODY_SIZE) {
|
|
334
|
+
// The error body has grown too large to buffer for a replay if the
|
|
335
|
+
// retry is declined. Flush the buffered chunks downstream and fall
|
|
336
|
+
// back to passing the response through — it is no longer
|
|
337
|
+
// status-code retried. Previously the buffer was discarded here,
|
|
338
|
+
// which made #maybeError replay headers followed by zero body bytes.
|
|
339
|
+
const body = this.#body
|
|
204
340
|
this.#body = null
|
|
205
341
|
this.#bodySize = 0
|
|
342
|
+
|
|
343
|
+
this.#headersSent = true
|
|
344
|
+
let ret = super.onHeaders(this.#statusCode, this.#headers, () => this.#resume?.())
|
|
345
|
+
for (const buffered of body) {
|
|
346
|
+
if (this.#aborted) {
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
ret = super.onData(buffered)
|
|
350
|
+
}
|
|
351
|
+
return ret
|
|
206
352
|
}
|
|
207
353
|
}
|
|
208
354
|
}
|
|
@@ -214,6 +360,12 @@ class Handler extends DecoratorHandler {
|
|
|
214
360
|
return super.onComplete(trailers)
|
|
215
361
|
}
|
|
216
362
|
|
|
363
|
+
if (this.#headersSent && !this.#errorSent) {
|
|
364
|
+
// The >= 400 response was passed through (too large to buffer for
|
|
365
|
+
// replay) — headers and data have already been forwarded downstream.
|
|
366
|
+
return super.onComplete(trailers)
|
|
367
|
+
}
|
|
368
|
+
|
|
217
369
|
this.#maybeRetry(null)
|
|
218
370
|
}
|
|
219
371
|
|
|
@@ -229,6 +381,18 @@ class Handler extends DecoratorHandler {
|
|
|
229
381
|
}
|
|
230
382
|
|
|
231
383
|
#maybeError(err) {
|
|
384
|
+
if (!err && this.#aborted) {
|
|
385
|
+
// Downstream aborted (e.g. during the backoff wait between attempts).
|
|
386
|
+
// The replay branches below are suppressed by the aborted
|
|
387
|
+
// DecoratorHandler, so without this no terminal event would ever reach
|
|
388
|
+
// downstream (raw dispatch would hang forever), or a generic
|
|
389
|
+
// 'Response retry failed' would replace the abort reason.
|
|
390
|
+
// DecoratorHandler.onError still forwards after an abort, so deliver
|
|
391
|
+
// the abort reason as the terminal onError (exactly once, guarded by
|
|
392
|
+
// #errorSent below).
|
|
393
|
+
err = this.#reason ?? new RequestAbortedError()
|
|
394
|
+
}
|
|
395
|
+
|
|
232
396
|
if (err) {
|
|
233
397
|
if (!this.#errorSent) {
|
|
234
398
|
this.#errorSent = true
|
|
@@ -264,7 +428,22 @@ class Handler extends DecoratorHandler {
|
|
|
264
428
|
}
|
|
265
429
|
|
|
266
430
|
#maybeRetry(err) {
|
|
267
|
-
if (
|
|
431
|
+
if (
|
|
432
|
+
this.#aborted ||
|
|
433
|
+
isDisturbed(this.#opts.body) ||
|
|
434
|
+
// Once headers have been forwarded, a range resume is the only option —
|
|
435
|
+
// but it is impossible when the response wasn't tracked for resumption
|
|
436
|
+
// (#pos == null, e.g. a trailer response or a passed-through >= 400
|
|
437
|
+
// body) or #end is not positive: zero means nothing left to request
|
|
438
|
+
// (`bytes=0--1` is invalid), negative means the server sent a bogus
|
|
439
|
+
// content-length (e.g. `-5`). A non-positive #end can enter both from
|
|
440
|
+
// the initial response and from a full-200 restart, so guard here at
|
|
441
|
+
// the single resume decision point, mirroring the precondition asserted
|
|
442
|
+
// below. Without this, the resume asserts would throw and be delivered
|
|
443
|
+
// to the user IN PLACE of the original error.
|
|
444
|
+
(this.#headersSent && (this.#pos == null || (this.#end != null && this.#end <= 0))) ||
|
|
445
|
+
(this.#pos && !this.#etag)
|
|
446
|
+
) {
|
|
268
447
|
this.#maybeError(err)
|
|
269
448
|
return
|
|
270
449
|
}
|
|
@@ -309,8 +488,29 @@ class Handler extends DecoratorHandler {
|
|
|
309
488
|
assert(Number.isFinite(this.#pos))
|
|
310
489
|
assert(this.#end == null || (Number.isFinite(this.#end) && this.#end > 0))
|
|
311
490
|
|
|
312
|
-
|
|
313
|
-
|
|
491
|
+
// Direct dispatch()/compose() callers may pass undici's legal flat
|
|
492
|
+
// [name, value, ...] array headers; spreading that form would send
|
|
493
|
+
// garbage numeric header names ('0', '1', ...) on the wire instead
|
|
494
|
+
// of the real ones. Normalize to an object first (same as
|
|
495
|
+
// redirect.js does).
|
|
496
|
+
this.#opts = {
|
|
497
|
+
...this.#opts,
|
|
498
|
+
headers: Array.isArray(this.#opts.headers)
|
|
499
|
+
? parseHeaders(this.#opts.headers)
|
|
500
|
+
: { ...this.#opts.headers },
|
|
501
|
+
}
|
|
502
|
+
// A pos 0 resume is allowed without an etag (nothing was forwarded
|
|
503
|
+
// yet, so nothing can tear) — but then there is no etag to validate
|
|
504
|
+
// against. Only send if-match when we actually hold one; a null
|
|
505
|
+
// value would go on the wire as an invalid empty `if-match:` header.
|
|
506
|
+
// Delete any if-match a PREVIOUS resume attempt wrote first: the
|
|
507
|
+
// spread above copies it from the reassigned opts, and #etag may
|
|
508
|
+
// have been cleared since (e.g. a full-200 restart with a weak or
|
|
509
|
+
// missing etag) — the stale validator must not go on the wire.
|
|
510
|
+
delete this.#opts.headers['if-match']
|
|
511
|
+
if (typeof this.#etag === 'string') {
|
|
512
|
+
this.#opts.headers['if-match'] = this.#etag
|
|
513
|
+
}
|
|
314
514
|
this.#opts.headers.range = `bytes=${this.#pos}-${this.#end ? this.#end - 1 : ''}`
|
|
315
515
|
this.#opts.logger?.debug({ err, retryCount: this.#retryCount }, 'retry response body')
|
|
316
516
|
|
|
@@ -321,11 +521,67 @@ class Handler extends DecoratorHandler {
|
|
|
321
521
|
}
|
|
322
522
|
})
|
|
323
523
|
.catch((err) => {
|
|
324
|
-
|
|
524
|
+
// When the downstream abort cancelled the backoff timer, the timer's
|
|
525
|
+
// own AbortError rejection is just plumbing — deliver the recorded
|
|
526
|
+
// abort reason instead (falsy reasons are normalized in #maybeError).
|
|
527
|
+
this.#maybeError(this.#aborted ? this.#reason : err)
|
|
325
528
|
})
|
|
326
529
|
}
|
|
327
530
|
|
|
531
|
+
// Backoff wait that is abortable by the handler-chain abort (via the
|
|
532
|
+
// internal AbortController the onConnect wrapper aborts) AND by opts.signal —
|
|
533
|
+
// otherwise a downstream abort during the wait leaves a ref'd timer holding
|
|
534
|
+
// the event loop for up to 60s (retry-after).
|
|
535
|
+
//
|
|
536
|
+
// The internal controller's signal is always a real AbortSignal, so it is
|
|
537
|
+
// the single thing the wait races against:
|
|
538
|
+
// - a real AbortSignal opts.signal is composed with AbortSignal.any (the
|
|
539
|
+
// idiomatic path; a real AbortSignal already carries the add/remove pair
|
|
540
|
+
// subscribeAbort would look for, so .any subsumes it), and
|
|
541
|
+
// - an EventEmitter-style opts.signal (the library also accepts those, see
|
|
542
|
+
// RequestHandler in lib/request.js) — which AbortSignal.any cannot take —
|
|
543
|
+
// is bridged into the internal controller via the same crash-safe
|
|
544
|
+
// subscribeAbort used by sleep(): its 'abort' aborts the controller with
|
|
545
|
+
// the signal's reason. A signal with no complete subscribe/unsubscribe
|
|
546
|
+
// pair (raw dispatch() garbage) yields no bridge and the wait is still
|
|
547
|
+
// cancellable by the internal controller, so the retry proceeds.
|
|
548
|
+
// sleep() then takes its tp.setTimeout fast path on the resulting real
|
|
549
|
+
// AbortSignal.
|
|
550
|
+
#backoff(delay, opts) {
|
|
551
|
+
this.#retryAbortController ??= new AbortController()
|
|
552
|
+
const controller = this.#retryAbortController
|
|
553
|
+
const signal = opts?.signal
|
|
554
|
+
|
|
555
|
+
if (signal == null) {
|
|
556
|
+
return sleep(delay, controller.signal)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (signal instanceof AbortSignal) {
|
|
560
|
+
return sleep(delay, AbortSignal.any([controller.signal, signal]))
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (signal.aborted) {
|
|
564
|
+
controller.abort(signal.reason ?? new RequestAbortedError())
|
|
565
|
+
return sleep(delay, controller.signal)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const removeAbortListener =
|
|
569
|
+
subscribeAbort(signal, () => controller.abort(signal.reason ?? new RequestAbortedError())) ??
|
|
570
|
+
noop
|
|
571
|
+
const wait = sleep(delay, controller.signal)
|
|
572
|
+
// Detach the bridge once the wait settles so an EE signal that outlives the
|
|
573
|
+
// backoff (e.g. aborted after a successful retry) is not still referenced.
|
|
574
|
+
return wait.finally(removeAbortListener)
|
|
575
|
+
}
|
|
576
|
+
|
|
328
577
|
async #retryFn(err, retryCount, opts) {
|
|
578
|
+
if (this.#aborted) {
|
|
579
|
+
// A user retry callback may invoke this after downstream has already
|
|
580
|
+
// aborted — don't start a backoff timer that nothing will cancel.
|
|
581
|
+
// #maybeRetry's promise chain observes #aborted and delivers #reason.
|
|
582
|
+
return false
|
|
583
|
+
}
|
|
584
|
+
|
|
329
585
|
let retryOpts = opts?.retry
|
|
330
586
|
|
|
331
587
|
if (!retryOpts) {
|
|
@@ -362,7 +618,7 @@ class Handler extends DecoratorHandler {
|
|
|
362
618
|
Math.min(retryAfter, 60e3)
|
|
363
619
|
: Math.min(10e3, retryCount * 1e3)
|
|
364
620
|
this.#opts.logger?.debug({ statusCode, retryAfter, delay, retryCount }, 'retry delay')
|
|
365
|
-
return
|
|
621
|
+
return this.#backoff(delay, opts)
|
|
366
622
|
}
|
|
367
623
|
|
|
368
624
|
if (
|
|
@@ -383,16 +639,12 @@ class Handler extends DecoratorHandler {
|
|
|
383
639
|
].includes(err.code)
|
|
384
640
|
) {
|
|
385
641
|
this.#opts.logger?.debug({ err, retryCount }, 'retry delay')
|
|
386
|
-
return
|
|
387
|
-
signal: opts?.signal ?? undefined,
|
|
388
|
-
})
|
|
642
|
+
return this.#backoff(Math.min(10e3, retryCount * 1e3), opts)
|
|
389
643
|
}
|
|
390
644
|
|
|
391
645
|
if (err?.message && ['other side closed'].includes(err.message)) {
|
|
392
646
|
this.#opts.logger?.debug({ err, retryCount }, 'retry delay')
|
|
393
|
-
return
|
|
394
|
-
signal: opts?.signal ?? undefined,
|
|
395
|
-
})
|
|
647
|
+
return this.#backoff(Math.min(10e3, retryCount * 1e3), opts)
|
|
396
648
|
}
|
|
397
649
|
|
|
398
650
|
return false
|
|
@@ -29,6 +29,16 @@ class Handler extends DecoratorHandler {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
onHeaders(statusCode, headers, resume) {
|
|
32
|
+
// Responses that by definition carry no body — 1xx informational, 204 No
|
|
33
|
+
// Content, 205 Reset Content, 304 Not Modified — must not be size/hash-
|
|
34
|
+
// verified. A 304 in particular may echo the Content-Length of the full
|
|
35
|
+
// representation it refers to; verifying the (absent) body against it would
|
|
36
|
+
// falsely trip the size check and break conditional-request revalidation.
|
|
37
|
+
// Leaving #expectedSize/#hasher null makes onData/onComplete no-ops here.
|
|
38
|
+
if (statusCode < 200 || statusCode === 204 || statusCode === 205 || statusCode === 304) {
|
|
39
|
+
return super.onHeaders(statusCode, headers, resume)
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
// A duplicated Content-MD5 header arrives as an array. Several identical
|
|
33
43
|
// copies (a CDN/proxy re-appending its own) describe the same digest, so
|
|
34
44
|
// collapse them to one; genuinely conflicting copies are kept as an array
|