undici 8.10.1 → 8.10.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.
@@ -101,8 +101,8 @@ body yourself.
101
101
 
102
102
  * `opts` {Object} (optional)
103
103
  * `maxSize` {number} Maximum number of bytes to read and discard. Responses
104
- whose `Content-Length` exceeds this value are aborted. **Default:**
105
- `1_048_576` (1 MiB).
104
+ whose declared or received body size exceeds this value are aborted.
105
+ **Default:** `1_048_576` (1 MiB).
106
106
 
107
107
  Per-request override: set `dumpMaxSize` on the dispatch options to override
108
108
  the global `maxSize` for a specific request.
@@ -210,6 +210,9 @@ Automatically decompresses response bodies encoded with `gzip`, `x-gzip`,
210
210
  skipped. **Default:** `[204, 304]`.
211
211
  * `skipErrorResponses` {boolean} When `true`, responses with a status code
212
212
  >= 400 are not decompressed. **Default:** `true`.
213
+ * `maxSize` {number} Maximum decompressed response size in bytes. The request
214
+ fails with a `ResponseExceededMaxSizeError` if the decoded body exceeds
215
+ this limit. **Default:** `67108864` (64 MiB).
213
216
 
214
217
  **Returns:** {Dispatcher.DispatcherComposeInterceptor}
215
218
 
@@ -221,7 +224,8 @@ import { Agent, interceptors } from 'undici'
221
224
  const agent = new Agent().compose(
222
225
  interceptors.decompress({
223
226
  skipStatusCodes: [204, 304],
224
- skipErrorResponses: false // decompress error bodies too
227
+ skipErrorResponses: false, // decompress error bodies too
228
+ maxSize: 16 * 1024 * 1024 // limit decoded bodies to 16 MiB
225
229
  })
226
230
  )
227
231
  ```
@@ -60,6 +60,11 @@ added: v7.23.0
60
60
  a password embedded in `proxyUrl`. **Default:** the URL password, if any.
61
61
  * `connect` {Function} Custom connector used to open the socket to the proxy.
62
62
  **Default:** a connector built from `proxyTls`.
63
+ * `connectTimeout` {number} Maximum time in milliseconds for each proxy
64
+ connection, SOCKS5 negotiation, and target TLS negotiation stage. A value of
65
+ `0` disables these stage timeouts. `proxyTls.timeout` and
66
+ `requestTls.timeout` override it for their respective TLS stages.
67
+ **Default:** `5000`.
63
68
  * `proxyTls` {BuildOptions} TLS options for the connection to the proxy itself
64
69
  (SOCKS5 over TLS). When set, the proxy connection is established over TLS and
65
70
  `servername` defaults to the proxy host name.
@@ -69,7 +74,8 @@ added: v7.23.0
69
74
  host name.
70
75
 
71
76
  Throws an `InvalidArgumentError` if `proxyUrl` is missing or does not use the
72
- `socks5:` or `socks:` protocol.
77
+ `socks5:` or `socks:` protocol, or if `connectTimeout`, `proxyTls.timeout`, or
78
+ `requestTls.timeout` is not a finite, non-negative number.
73
79
 
74
80
  ```mjs
75
81
  import { Socks5ProxyAgent } from 'undici'
@@ -87,7 +87,7 @@ class MemoryCacheStore extends EventEmitter {
87
87
  }
88
88
 
89
89
  /**
90
- * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req
90
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key
91
91
  * @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
92
92
  */
93
93
  get (key) {
@@ -5,6 +5,8 @@ module.exports = {
5
5
  kDestroy: Symbol('destroy'),
6
6
  kDispatch: Symbol('dispatch'),
7
7
  kUrl: Symbol('url'),
8
+ kRequestOrigin: Symbol('request origin'),
9
+ kOriginless: Symbol('originless'),
8
10
  kWriting: Symbol('writing'),
9
11
  kResuming: Symbol('resuming'),
10
12
  kQueue: Symbol('queue'),
@@ -13,7 +13,7 @@ const {
13
13
  kGetDispatcher
14
14
  } = require('./pool-base')
15
15
  const Pool = require('./pool')
16
- const { kUrl } = require('../core/symbols')
16
+ const { kOriginless, kUrl } = require('../core/symbols')
17
17
  const util = require('../core/util')
18
18
  const kFactory = Symbol('factory')
19
19
 
@@ -49,14 +49,17 @@ function defaultFactory (origin, opts) {
49
49
  }
50
50
 
51
51
  class BalancedPool extends PoolBase {
52
- constructor (upstreams = [], { factory = defaultFactory, ...opts } = {}) {
52
+ constructor (upstreams = [], { factory = defaultFactory, connect, tls, ...opts } = {}) {
53
53
  if (typeof factory !== 'function') {
54
54
  throw new InvalidArgumentError('factory must be a function.')
55
55
  }
56
56
 
57
57
  super(opts)
58
58
 
59
- this[kOptions] = { ...util.deepClone(opts) }
59
+ this[kOriginless] = true
60
+ if (connect && typeof connect !== 'function') connect = { ...connect }
61
+ if (tls && typeof tls !== 'function') tls = { ...tls }
62
+ this[kOptions] = { ...util.deepClone(opts), connect, tls }
60
63
  this[kIndex] = -1
61
64
  this[kCurrentWeight] = 0
62
65
 
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
  const EventEmitter = require('node:events')
3
- const { kUrl } = require('../core/symbols')
3
+ const { kOriginless, kUrl } = require('../core/symbols')
4
4
 
5
5
  class Dispatcher extends EventEmitter {
6
6
  dispatch () {
@@ -18,6 +18,10 @@ class Dispatcher extends EventEmitter {
18
18
  compose (...args) {
19
19
  // So we handle [interceptor1, interceptor2] or interceptor1, interceptor2, ...
20
20
  const interceptors = Array.isArray(args[0]) ? args[0] : args
21
+ // null disables origin-dependent interceptors; undefined uses opts.origin.
22
+ const interceptorOrigin = this[kOriginless] === true
23
+ ? null
24
+ : this[kUrl]?.origin
21
25
  let dispatch = this.dispatch.bind(this)
22
26
 
23
27
  for (const interceptor of interceptors) {
@@ -29,7 +33,7 @@ class Dispatcher extends EventEmitter {
29
33
  throw new TypeError(`invalid interceptor, expected function received ${typeof interceptor}`)
30
34
  }
31
35
 
32
- dispatch = interceptor(dispatch)
36
+ dispatch = interceptor(dispatch, interceptorOrigin)
33
37
 
34
38
  if (dispatch == null || typeof dispatch !== 'function' || dispatch.length !== 2) {
35
39
  throw new TypeError('invalid interceptor')
@@ -3,6 +3,7 @@
3
3
  const Dispatcher = require('./dispatcher')
4
4
  const { InvalidArgumentError } = require('../core/errors')
5
5
  const { toRawHeaders } = require('../core/util')
6
+ const { kOriginless, kUrl } = require('../core/symbols')
6
7
 
7
8
  class LegacyHandlerWrapper {
8
9
  #handler
@@ -71,6 +72,8 @@ class Dispatcher1Wrapper extends Dispatcher {
71
72
  }
72
73
 
73
74
  this.#dispatcher = dispatcher
75
+ this[kUrl] = dispatcher[kUrl]
76
+ this[kOriginless] = dispatcher[kOriginless]
74
77
  }
75
78
 
76
79
  static wrapHandler (handler) {
@@ -9,6 +9,7 @@ const buildConnector = require('../core/connect')
9
9
  const Client = require('./client')
10
10
  const { channels } = require('../core/diagnostics')
11
11
  const Socks5ProxyAgent = require('./socks5-proxy-agent')
12
+ const { hasSafeIterator } = require('../core/util')
12
13
 
13
14
  const kAgent = Symbol('proxy agent')
14
15
  const kClient = Symbol('proxy client')
@@ -161,6 +162,7 @@ class ProxyAgent extends DispatcherBase {
161
162
  factory: agentFactory,
162
163
  username: opts.username || username,
163
164
  password: opts.password || password,
165
+ connectTimeout,
164
166
  proxyTls: opts.proxyTls,
165
167
  requestTls: opts.requestTls
166
168
  })
@@ -344,6 +346,21 @@ function buildHeaders (headers) {
344
346
  return headersPair
345
347
  }
346
348
 
349
+ // Materialize iterable header containers (e.g. Map, Headers) into a record so
350
+ // that throwIfProxyAuthIsSent() can inspect their entries. Object.keys and
351
+ // for...in see nothing on a Map/Headers instance, so without this the
352
+ // Proxy-Authorization guard is bypassed and proxy credentials can reach the
353
+ // origin server (GHSA-6cv7-626c-qhqw).
354
+ if (headers && typeof headers === 'object' && hasSafeIterator(headers)) {
355
+ const headersPair = {}
356
+
357
+ for (const [key, value] of headers) {
358
+ headersPair[key] = value
359
+ }
360
+
361
+ return headersPair
362
+ }
363
+
347
364
  return headers
348
365
  }
349
366
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  const Dispatcher = require('./dispatcher')
4
4
  const RetryHandler = require('../handler/retry-handler')
5
+ const { kOriginless, kUrl } = require('../core/symbols')
5
6
 
6
7
  class RetryAgent extends Dispatcher {
7
8
  #agent = null
@@ -10,6 +11,8 @@ class RetryAgent extends Dispatcher {
10
11
  super(options)
11
12
  this.#agent = agent
12
13
  this.#options = options
14
+ this[kUrl] = agent[kUrl]
15
+ this[kOriginless] = agent[kOriginless]
13
16
  }
14
17
 
15
18
  dispatch (opts, handler) {
@@ -4,22 +4,33 @@ const { URL } = require('node:url')
4
4
 
5
5
  let tls // include tls conditionally since it is not always available
6
6
  const DispatcherBase = require('./dispatcher-base')
7
- const { InvalidArgumentError } = require('../core/errors')
7
+ const { ConnectTimeoutError, InvalidArgumentError } = require('../core/errors')
8
8
  const { Socks5Client, STATES } = require('../core/socks5-client')
9
9
  const { kBusy, kConnected, kDispatch, kClose, kDestroy } = require('../core/symbols')
10
10
  const Pool = require('./pool')
11
11
  const buildConnector = require('../core/connect')
12
+ const { setupConnectTimeout } = require('../core/util')
12
13
  const { debuglog } = require('node:util')
13
14
 
14
15
  const debug = debuglog('undici:socks5-proxy')
15
16
 
17
+ const DEFAULT_SOCKS5_CONNECT_TIMEOUT = 5000
18
+
16
19
  const kProxyUrl = Symbol('proxy url')
17
20
  const kProxyHeaders = Symbol('proxy headers')
18
21
  const kProxyAuth = Symbol('proxy auth')
19
22
  const kProxyProtocol = Symbol('proxy protocol')
20
23
  const kPools = Symbol('pools')
21
24
  const kConnector = Symbol('connector')
25
+ const kConnectTimeout = Symbol('connect timeout')
22
26
  const kRequestTls = Symbol('request tls settings')
27
+ const kRequestTlsTimeout = Symbol('request tls timeout')
28
+
29
+ function createConnectTimeoutError (hostname, port, timeout) {
30
+ return new ConnectTimeoutError(
31
+ `Connect Timeout Error (attempted address: ${hostname}:${port}, timeout: ${timeout}ms)`
32
+ )
33
+ }
23
34
 
24
35
  // Static flag to ensure warning is only emitted once per process
25
36
  let experimentalWarningEmitted = false
@@ -54,7 +65,20 @@ class Socks5ProxyAgent extends DispatcherBase {
54
65
  this[kProxyUrl] = url
55
66
  this[kProxyHeaders] = options.headers || {}
56
67
  this[kProxyProtocol] = options.proxyTls ? 'https:' : 'http:'
57
- this[kRequestTls] = options.requestTls
68
+
69
+ const connectTimeout = options.connectTimeout ?? DEFAULT_SOCKS5_CONNECT_TIMEOUT
70
+ if (!Number.isFinite(connectTimeout) || connectTimeout < 0) {
71
+ throw new InvalidArgumentError('invalid connectTimeout')
72
+ }
73
+ this[kConnectTimeout] = connectTimeout
74
+
75
+ const { timeout, ...requestTls } = options.requestTls || {}
76
+ const requestTlsTimeout = timeout ?? connectTimeout
77
+ if (!Number.isFinite(requestTlsTimeout) || requestTlsTimeout < 0) {
78
+ throw new InvalidArgumentError('invalid requestTls.timeout')
79
+ }
80
+ this[kRequestTls] = requestTls
81
+ this[kRequestTlsTimeout] = requestTlsTimeout
58
82
 
59
83
  // Extract auth from URL or options
60
84
  this[kProxyAuth] = {
@@ -63,8 +87,13 @@ class Socks5ProxyAgent extends DispatcherBase {
63
87
  }
64
88
 
65
89
  // Create connector for proxy connection
90
+ const proxyTlsTimeout = options.proxyTls?.timeout ?? connectTimeout
91
+ if (!Number.isFinite(proxyTlsTimeout) || proxyTlsTimeout < 0) {
92
+ throw new InvalidArgumentError('invalid proxyTls.timeout')
93
+ }
66
94
  this[kConnector] = options.connect || buildConnector({
67
95
  ...options.proxyTls,
96
+ timeout: proxyTlsTimeout,
68
97
  servername: options.proxyTls?.servername || url.hostname
69
98
  })
70
99
 
@@ -113,21 +142,29 @@ class Socks5ProxyAgent extends DispatcherBase {
113
142
 
114
143
  // Wait for authentication (if required)
115
144
  const authenticationReady = Promise.withResolvers()
116
-
117
- const authenticationTimeout = setTimeout(() => {
118
- socks5Client.destroy()
119
- authenticationReady.reject(new Error('SOCKS5 authentication timeout'))
120
- }, 5000)
121
-
122
- const onAuthenticated = () => {
145
+ const authenticationTimeout = this[kConnectTimeout] === 0
146
+ ? null
147
+ : setTimeout(() => {
148
+ cleanupAuthenticationListeners()
149
+ socks5Client.destroy()
150
+ authenticationReady.reject(
151
+ createConnectTimeoutError(proxyHost, proxyPort, this[kConnectTimeout])
152
+ )
153
+ }, this[kConnectTimeout])
154
+
155
+ const cleanupAuthenticationListeners = () => {
123
156
  clearTimeout(authenticationTimeout)
157
+ socks5Client.removeListener('authenticated', onAuthenticated)
124
158
  socks5Client.removeListener('error', onAuthenticationError)
159
+ }
160
+
161
+ const onAuthenticated = () => {
162
+ cleanupAuthenticationListeners()
125
163
  authenticationReady.resolve()
126
164
  }
127
165
 
128
166
  const onAuthenticationError = (err) => {
129
- clearTimeout(authenticationTimeout)
130
- socks5Client.removeListener('authenticated', onAuthenticated)
167
+ cleanupAuthenticationListeners()
131
168
  authenticationReady.reject(err)
132
169
  }
133
170
 
@@ -147,22 +184,30 @@ class Socks5ProxyAgent extends DispatcherBase {
147
184
 
148
185
  // Wait for connection
149
186
  const connectionReady = Promise.withResolvers()
150
-
151
- const connectionTimeout = setTimeout(() => {
152
- socks5Client.destroy()
153
- connectionReady.reject(new Error('SOCKS5 connection timeout'))
154
- }, 5000)
187
+ const connectionTimeout = this[kConnectTimeout] === 0
188
+ ? null
189
+ : setTimeout(() => {
190
+ cleanupConnectionListeners()
191
+ socks5Client.destroy()
192
+ connectionReady.reject(
193
+ createConnectTimeoutError(targetHost, targetPort, this[kConnectTimeout])
194
+ )
195
+ }, this[kConnectTimeout])
196
+
197
+ const cleanupConnectionListeners = () => {
198
+ clearTimeout(connectionTimeout)
199
+ socks5Client.removeListener('connected', onConnected)
200
+ socks5Client.removeListener('error', onConnectionError)
201
+ }
155
202
 
156
203
  const onConnected = (info) => {
157
204
  debug('SOCKS5 tunnel established to', targetHost, targetPort, 'via', info)
158
- clearTimeout(connectionTimeout)
159
- socks5Client.removeListener('error', onConnectionError)
205
+ cleanupConnectionListeners()
160
206
  connectionReady.resolve()
161
207
  }
162
208
 
163
209
  const onConnectionError = (err) => {
164
- clearTimeout(connectionTimeout)
165
- socks5Client.removeListener('connected', onConnected)
210
+ cleanupConnectionListeners()
166
211
  connectionReady.reject(err)
167
212
  }
168
213
 
@@ -215,8 +260,31 @@ class Socks5ProxyAgent extends DispatcherBase {
215
260
  })
216
261
 
217
262
  const tlsReady = Promise.withResolvers()
218
- finalSocket.once('secureConnect', tlsReady.resolve)
219
- finalSocket.once('error', tlsReady.reject)
263
+
264
+ const cleanupTlsListeners = () => {
265
+ queueMicrotask(clearTlsTimeout)
266
+ finalSocket.removeListener('secureConnect', onSecureConnect)
267
+ finalSocket.removeListener('error', onTlsError)
268
+ }
269
+
270
+ const onSecureConnect = () => {
271
+ cleanupTlsListeners()
272
+ tlsReady.resolve()
273
+ }
274
+
275
+ const onTlsError = (err) => {
276
+ cleanupTlsListeners()
277
+ tlsReady.reject(err)
278
+ }
279
+
280
+ const clearTlsTimeout = setupConnectTimeout(new WeakRef(finalSocket), {
281
+ timeout: this[kRequestTlsTimeout],
282
+ hostname: targetHost,
283
+ port: targetPort
284
+ })
285
+
286
+ finalSocket.once('secureConnect', onSecureConnect)
287
+ finalSocket.once('error', onTlsError)
220
288
  await tlsReady.promise
221
289
  }
222
290
 
@@ -218,6 +218,13 @@ class CacheHandler {
218
218
  }
219
219
 
220
220
  const cacheControlHeader = resHeaders['cache-control']
221
+ const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
222
+
223
+ if (revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives)) {
224
+ deleteCachedValue(this.#store, this.#cacheKey)
225
+ return downstreamOnHeaders()
226
+ }
227
+
221
228
  const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode)
222
229
  if (
223
230
  !cacheControlHeader &&
@@ -234,8 +241,7 @@ class CacheHandler {
234
241
  return downstreamOnHeaders()
235
242
  }
236
243
 
237
- const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
238
- if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) {
244
+ if (!canCacheResponse(this.#cacheType, this.#cacheKey.method, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) {
239
245
  if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) {
240
246
  deleteCachedValue(this.#store, this.#cacheKey)
241
247
  }
@@ -484,7 +490,10 @@ function deleteCachedValueIfNotModified (statusCode, store, cacheKey) {
484
490
  */
485
491
  function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) {
486
492
  return cacheControlDirectives['no-store'] === true ||
487
- (cacheType === 'shared' && cacheControlDirectives.private === true) ||
493
+ (cacheType === 'shared' && (
494
+ cacheControlDirectives.private === true ||
495
+ Object.hasOwn(resHeaders, 'set-cookie')
496
+ )) ||
488
497
  (resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false)
489
498
  }
490
499
 
@@ -492,12 +501,16 @@ function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheC
492
501
  * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
493
502
  *
494
503
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
504
+ * @param {string} method
495
505
  * @param {number} statusCode
496
506
  * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
497
507
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
498
508
  * @param {import('../../types/header.d.ts').IncomingHttpHeaders} [reqHeaders]
499
509
  */
500
- function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) {
510
+ function canCacheResponse (cacheType, method, statusCode, resHeaders, cacheControlDirectives, reqHeaders) {
511
+ if (!arrayIncludes(util.safeHTTPMethods, method)) {
512
+ return false
513
+ }
501
514
  // Status code must be final and understood.
502
515
  if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) {
503
516
  return false
@@ -518,7 +531,10 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect
518
531
  return false
519
532
  }
520
533
 
521
- if (cacheType === 'shared' && cacheControlDirectives.private === true) {
534
+ if (cacheType === 'shared' && (
535
+ cacheControlDirectives.private === true ||
536
+ Object.hasOwn(resHeaders, 'set-cookie')
537
+ )) {
522
538
  return false
523
539
  }
524
540
 
@@ -3,6 +3,7 @@
3
3
  const util = require('../core/util')
4
4
  const assert = require('node:assert')
5
5
  const { InvalidArgumentError } = require('../core/errors')
6
+ const { kRequestOrigin } = require('../core/symbols')
6
7
 
7
8
  const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
8
9
 
@@ -89,8 +90,12 @@ class RedirectHandler {
89
90
  ? null
90
91
  : headers.location
91
92
 
92
- if (this.opts.origin) {
93
- this.history.push(new URL(this.opts.path, this.opts.origin))
93
+ const requestOrigin = this.opts[kRequestOrigin] === undefined
94
+ ? this.opts.origin
95
+ : this.opts[kRequestOrigin]
96
+
97
+ if (requestOrigin) {
98
+ this.history.push(new URL(this.opts.path, requestOrigin))
94
99
  }
95
100
 
96
101
  if (!this.location) {
@@ -98,7 +103,10 @@ class RedirectHandler {
98
103
  return
99
104
  }
100
105
 
101
- const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin)))
106
+ const baseUrl = requestOrigin
107
+ ? new URL(this.opts.path, requestOrigin)
108
+ : undefined
109
+ const { origin, pathname, search } = util.parseURL(new URL(this.location, baseUrl))
102
110
  const path = search ? `${pathname}${search}` : pathname
103
111
 
104
112
  // Check for redirect loops by seeing if we've already visited this URL in our history
@@ -114,9 +122,10 @@ class RedirectHandler {
114
122
  // Remove headers referring to the original URL.
115
123
  // By default it is Host only. A 303 or a 301/302 POST-to-GET redirect also removes all Content-* headers.
116
124
  // https://tools.ietf.org/html/rfc7231#section-6.4
117
- this.opts.headers = cleanRequestHeaders(this.opts.headers, removeContentHeaders, this.opts.origin !== origin, this.stripHeadersOnRedirect, this.stripHeadersOnCrossOriginRedirect)
125
+ this.opts.headers = cleanRequestHeaders(this.opts.headers, removeContentHeaders, requestOrigin !== origin, this.stripHeadersOnRedirect, this.stripHeadersOnCrossOriginRedirect)
118
126
  this.opts.path = path
119
127
  this.opts.origin = origin
128
+ this.opts[kRequestOrigin] = origin
120
129
  this.opts.query = null
121
130
  }
122
131
 
@@ -65,7 +65,18 @@ class RetryController {
65
65
  get aborted () { return this.target?.aborted ?? false }
66
66
  get reason () { return this.target?.reason ?? null }
67
67
  get rawHeaders () { return this.target?.rawHeaders ?? null }
68
+ set rawHeaders (value) {
69
+ if (this.target) {
70
+ this.target.rawHeaders = value
71
+ }
72
+ }
73
+
68
74
  get rawTrailers () { return this.target?.rawTrailers ?? null }
75
+ set rawTrailers (value) {
76
+ if (this.target) {
77
+ this.target.rawTrailers = value
78
+ }
79
+ }
69
80
  }
70
81
 
71
82
  class RetryHandler {
@@ -140,8 +151,16 @@ class RetryHandler {
140
151
  if (this.retryOpts.throwOnError) {
141
152
  // Preserve old behavior for status codes that are not eligible for retry
142
153
  if (this.retryOpts.statusCodes.includes(statusCode) === false) {
143
- this.headersSent = true
144
- this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
154
+ if (this.headersSent) {
155
+ // The downstream handler already received the response from an
156
+ // earlier attempt. Forwarding this response would replace the
157
+ // downstream body and leave the original body pending forever.
158
+ this.handler.onResponseError?.(this.controllerProxy, err)
159
+ } else {
160
+ this.headersSent = true
161
+ this.checkpointResponseEnd(headers)
162
+ this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
163
+ }
145
164
  } else {
146
165
  this.error = err
147
166
  }
@@ -151,6 +170,7 @@ class RetryHandler {
151
170
 
152
171
  if (isDisturbed(this.opts.body)) {
153
172
  this.headersSent = true
173
+ this.checkpointResponseEnd(headers)
154
174
  this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
155
175
  return
156
176
  }
@@ -164,8 +184,16 @@ class RetryHandler {
164
184
  this.retryTimer = null
165
185
 
166
186
  if (passedErr) {
167
- this.headersSent = true
168
- this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
187
+ if (this.headersSent) {
188
+ // The downstream handler already received the response from an
189
+ // earlier attempt. Forwarding this response would replace the
190
+ // downstream body and leave the original body pending forever.
191
+ this.handler.onResponseError?.(this.controllerProxy, passedErr)
192
+ } else {
193
+ this.headersSent = true
194
+ this.checkpointResponseEnd(headers)
195
+ this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
196
+ }
169
197
  controller.resume()
170
198
  return
171
199
  }
@@ -195,6 +223,20 @@ class RetryHandler {
195
223
  ) ?? null
196
224
  }
197
225
 
226
+ checkpointResponseEnd (headers) {
227
+ if (this.end == null && this.opts.method !== 'HEAD') {
228
+ const contentLength = headers['content-length']
229
+ this.end = contentLength != null ? Number(contentLength) - 1 : null
230
+
231
+ assert(
232
+ this.end == null || Number.isFinite(this.end),
233
+ 'invalid content-length'
234
+ )
235
+
236
+ this.resume = this.end != null
237
+ }
238
+ }
239
+
198
240
  onRequestStart (controller, context) {
199
241
  // request.js creates a fresh RequestController per dispatch and passes that
200
242
  // same instance to every later callback of the dispatch. onRequestStart is
@@ -293,18 +335,6 @@ class RetryHandler {
293
335
  this.statusCode = statusCode
294
336
  this.headers = headers
295
337
 
296
- if (statusCode >= 300) {
297
- const err = new RequestRetryError('Request failed', statusCode, {
298
- headers,
299
- data: {
300
- count: this.retryCount
301
- }
302
- })
303
-
304
- this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
305
- return
306
- }
307
-
308
338
  // Checkpoint for resume from where we left it
309
339
  if (this.headersSent) {
310
340
  // Only Partial Content 206 supposed to provide Content-Range,
@@ -341,12 +371,28 @@ class RetryHandler {
341
371
 
342
372
  const { start, size, end = size ? size - 1 : null } = contentRange
343
373
 
344
- assert(this.start === start, 'content-range mismatch')
345
- assert(this.end == null || this.end === end, 'content-range mismatch')
374
+ if (this.start !== start || (this.end != null && this.end !== end)) {
375
+ throw new RequestRetryError('Content-Range mismatch', statusCode, {
376
+ headers,
377
+ data: { count: this.retryCount }
378
+ })
379
+ }
346
380
 
347
381
  return
348
382
  }
349
383
 
384
+ if (statusCode >= 300) {
385
+ const err = new RequestRetryError('Request failed', statusCode, {
386
+ headers,
387
+ data: {
388
+ count: this.retryCount
389
+ }
390
+ })
391
+
392
+ this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
393
+ return
394
+ }
395
+
350
396
  if (this.end == null) {
351
397
  if (statusCode === 206) {
352
398
  // First time we receive 206
@@ -485,7 +531,7 @@ class RetryHandler {
485
531
 
486
532
  // controller is THIS failed connection (not the proxy): we inspect whether
487
533
  // the consumer aborted it to decide retry-vs-propagate.
488
- if (controller?.aborted || isDisturbed(this.opts.body)) {
534
+ if (controller?.aborted || isDisturbed(this.opts.body) || (this.headersSent && !this.resume)) {
489
535
  this.handler.onResponseError?.(this.controllerProxy, err)
490
536
  return
491
537
  }