@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.
@@ -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
- const { origin, pathname, search } = parseURL(
96
- new URL(this.#location, this.#opts.origin && new URL(this.#opts.path, this.#opts.origin)),
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 !== 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
- if (typeof body === 'string' || body instanceof Buffer) {
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.pause()
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
- this.#ac.abort(err)
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
- export default () => (dispatch) => (opts, handler) =>
82
- typeof opts.body !== 'function'
83
- ? dispatch(opts, handler)
84
- : dispatch({ ...opts, body: new FactoryStream(opts.body).on('error', noop) }, handler)
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
- super.onConnect(abort)
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 { DecoratorHandler, isDisturbed, decorateError, parseContentRange } from '../utils.js'
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
- if (this.#abort) {
64
- this.#abort(reason)
65
- } else {
66
- this.#reason = reason
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
- this.#maybeError(this.#retryError)
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 > 256 * 1024) {
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 (this.#aborted || isDisturbed(this.#opts.body) || (this.#pos && !this.#etag)) {
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
- this.#opts = { ...this.#opts, headers: { ...this.#opts.headers } }
313
- this.#opts.headers['if-match'] = this.#etag
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
- this.#maybeError(err)
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 tp.setTimeout(delay, true, { signal: opts?.signal ?? undefined })
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 tp.setTimeout(Math.min(10e3, retryCount * 1e3), true, {
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 tp.setTimeout(Math.min(10e3, retryCount * 1e3), true, {
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