undici 5.27.2 → 5.28.0

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.
@@ -0,0 +1,336 @@
1
+ const assert = require('node:assert')
2
+
3
+ const { kRetryHandlerDefaultRetry } = require('../core/symbols')
4
+ const { RequestRetryError } = require('../core/errors')
5
+ const { isDisturbed, parseHeaders, parseRangeHeader } = require('../core/util')
6
+
7
+ function calculateRetryAfterHeader (retryAfter) {
8
+ const current = Date.now()
9
+ const diff = new Date(retryAfter).getTime() - current
10
+
11
+ return diff
12
+ }
13
+
14
+ class RetryHandler {
15
+ constructor (opts, handlers) {
16
+ const { retryOptions, ...dispatchOpts } = opts
17
+ const {
18
+ // Retry scoped
19
+ retry: retryFn,
20
+ maxRetries,
21
+ maxTimeout,
22
+ minTimeout,
23
+ timeoutFactor,
24
+ // Response scoped
25
+ methods,
26
+ errorCodes,
27
+ retryAfter,
28
+ statusCodes
29
+ } = retryOptions ?? {}
30
+
31
+ this.dispatch = handlers.dispatch
32
+ this.handler = handlers.handler
33
+ this.opts = dispatchOpts
34
+ this.abort = null
35
+ this.aborted = false
36
+ this.retryOpts = {
37
+ retry: retryFn ?? RetryHandler[kRetryHandlerDefaultRetry],
38
+ retryAfter: retryAfter ?? true,
39
+ maxTimeout: maxTimeout ?? 30 * 1000, // 30s,
40
+ timeout: minTimeout ?? 500, // .5s
41
+ timeoutFactor: timeoutFactor ?? 2,
42
+ maxRetries: maxRetries ?? 5,
43
+ // What errors we should retry
44
+ methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'],
45
+ // Indicates which errors to retry
46
+ statusCodes: statusCodes ?? [500, 502, 503, 504, 429],
47
+ // List of errors to retry
48
+ errorCodes: errorCodes ?? [
49
+ 'ECONNRESET',
50
+ 'ECONNREFUSED',
51
+ 'ENOTFOUND',
52
+ 'ENETDOWN',
53
+ 'ENETUNREACH',
54
+ 'EHOSTDOWN',
55
+ 'EHOSTUNREACH',
56
+ 'EPIPE'
57
+ ]
58
+ }
59
+
60
+ this.retryCount = 0
61
+ this.start = 0
62
+ this.end = null
63
+ this.etag = null
64
+ this.resume = null
65
+
66
+ // Handle possible onConnect duplication
67
+ this.handler.onConnect(reason => {
68
+ this.aborted = true
69
+ if (this.abort) {
70
+ this.abort(reason)
71
+ } else {
72
+ this.reason = reason
73
+ }
74
+ })
75
+ }
76
+
77
+ onRequestSent () {
78
+ if (this.handler.onRequestSent) {
79
+ this.handler.onRequestSent()
80
+ }
81
+ }
82
+
83
+ onUpgrade (statusCode, headers, socket) {
84
+ if (this.handler.onUpgrade) {
85
+ this.handler.onUpgrade(statusCode, headers, socket)
86
+ }
87
+ }
88
+
89
+ onConnect (abort) {
90
+ if (this.aborted) {
91
+ abort(this.reason)
92
+ } else {
93
+ this.abort = abort
94
+ }
95
+ }
96
+
97
+ onBodySent (chunk) {
98
+ return this.handler.onBodySent(chunk)
99
+ }
100
+
101
+ static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) {
102
+ const { statusCode, code, headers } = err
103
+ const { method, retryOptions } = opts
104
+ const {
105
+ maxRetries,
106
+ timeout,
107
+ maxTimeout,
108
+ timeoutFactor,
109
+ statusCodes,
110
+ errorCodes,
111
+ methods
112
+ } = retryOptions
113
+ let { counter, currentTimeout } = state
114
+
115
+ currentTimeout =
116
+ currentTimeout != null && currentTimeout > 0 ? currentTimeout : timeout
117
+
118
+ // Any code that is not a Undici's originated and allowed to retry
119
+ if (
120
+ code &&
121
+ code !== 'UND_ERR_REQ_RETRY' &&
122
+ code !== 'UND_ERR_SOCKET' &&
123
+ !errorCodes.includes(code)
124
+ ) {
125
+ cb(err)
126
+ return
127
+ }
128
+
129
+ // If a set of method are provided and the current method is not in the list
130
+ if (Array.isArray(methods) && !methods.includes(method)) {
131
+ cb(err)
132
+ return
133
+ }
134
+
135
+ // If a set of status code are provided and the current status code is not in the list
136
+ if (
137
+ statusCode != null &&
138
+ Array.isArray(statusCodes) &&
139
+ !statusCodes.includes(statusCode)
140
+ ) {
141
+ cb(err)
142
+ return
143
+ }
144
+
145
+ // If we reached the max number of retries
146
+ if (counter > maxRetries) {
147
+ cb(err)
148
+ return
149
+ }
150
+
151
+ let retryAfterHeader = headers != null && headers['retry-after']
152
+ if (retryAfterHeader) {
153
+ retryAfterHeader = Number(retryAfterHeader)
154
+ retryAfterHeader = isNaN(retryAfterHeader)
155
+ ? calculateRetryAfterHeader(retryAfterHeader)
156
+ : retryAfterHeader * 1e3 // Retry-After is in seconds
157
+ }
158
+
159
+ const retryTimeout =
160
+ retryAfterHeader > 0
161
+ ? Math.min(retryAfterHeader, maxTimeout)
162
+ : Math.min(currentTimeout * timeoutFactor ** counter, maxTimeout)
163
+
164
+ state.currentTimeout = retryTimeout
165
+
166
+ setTimeout(() => cb(null), retryTimeout)
167
+ }
168
+
169
+ onHeaders (statusCode, rawHeaders, resume, statusMessage) {
170
+ const headers = parseHeaders(rawHeaders)
171
+
172
+ this.retryCount += 1
173
+
174
+ if (statusCode >= 300) {
175
+ this.abort(
176
+ new RequestRetryError('Request failed', statusCode, {
177
+ headers,
178
+ count: this.retryCount
179
+ })
180
+ )
181
+ return false
182
+ }
183
+
184
+ // Checkpoint for resume from where we left it
185
+ if (this.resume != null) {
186
+ this.resume = null
187
+
188
+ if (statusCode !== 206) {
189
+ return true
190
+ }
191
+
192
+ const contentRange = parseRangeHeader(headers['content-range'])
193
+ // If no content range
194
+ if (!contentRange) {
195
+ this.abort(
196
+ new RequestRetryError('Content-Range mismatch', statusCode, {
197
+ headers,
198
+ count: this.retryCount
199
+ })
200
+ )
201
+ return false
202
+ }
203
+
204
+ // Let's start with a weak etag check
205
+ if (this.etag != null && this.etag !== headers.etag) {
206
+ this.abort(
207
+ new RequestRetryError('ETag mismatch', statusCode, {
208
+ headers,
209
+ count: this.retryCount
210
+ })
211
+ )
212
+ return false
213
+ }
214
+
215
+ const { start, size, end = size } = contentRange
216
+
217
+ assert(this.start === start, 'content-range mismatch')
218
+ assert(this.end == null || this.end === end, 'content-range mismatch')
219
+
220
+ this.resume = resume
221
+ return true
222
+ }
223
+
224
+ if (this.end == null) {
225
+ if (statusCode === 206) {
226
+ // First time we receive 206
227
+ const range = parseRangeHeader(headers['content-range'])
228
+
229
+ if (range == null) {
230
+ return this.handler.onHeaders(
231
+ statusCode,
232
+ rawHeaders,
233
+ resume,
234
+ statusMessage
235
+ )
236
+ }
237
+
238
+ const { start, size, end = size } = range
239
+
240
+ assert(
241
+ start != null && Number.isFinite(start) && this.start !== start,
242
+ 'content-range mismatch'
243
+ )
244
+ assert(Number.isFinite(start))
245
+ assert(
246
+ end != null && Number.isFinite(end) && this.end !== end,
247
+ 'invalid content-length'
248
+ )
249
+
250
+ this.start = start
251
+ this.end = end
252
+ }
253
+
254
+ // We make our best to checkpoint the body for further range headers
255
+ if (this.end == null) {
256
+ const contentLength = headers['content-length']
257
+ this.end = contentLength != null ? Number(contentLength) : null
258
+ }
259
+
260
+ assert(Number.isFinite(this.start))
261
+ assert(
262
+ this.end == null || Number.isFinite(this.end),
263
+ 'invalid content-length'
264
+ )
265
+
266
+ this.resume = resume
267
+ this.etag = headers.etag != null ? headers.etag : null
268
+
269
+ return this.handler.onHeaders(
270
+ statusCode,
271
+ rawHeaders,
272
+ resume,
273
+ statusMessage
274
+ )
275
+ }
276
+
277
+ const err = new RequestRetryError('Request failed', statusCode, {
278
+ headers,
279
+ count: this.retryCount
280
+ })
281
+
282
+ this.abort(err)
283
+
284
+ return false
285
+ }
286
+
287
+ onData (chunk) {
288
+ this.start += chunk.length
289
+
290
+ return this.handler.onData(chunk)
291
+ }
292
+
293
+ onComplete (rawTrailers) {
294
+ this.retryCount = 0
295
+ return this.handler.onComplete(rawTrailers)
296
+ }
297
+
298
+ onError (err) {
299
+ if (this.aborted || isDisturbed(this.opts.body)) {
300
+ return this.handler.onError(err)
301
+ }
302
+
303
+ this.retryOpts.retry(
304
+ err,
305
+ {
306
+ state: { counter: this.retryCount++, currentTimeout: this.retryAfter },
307
+ opts: { retryOptions: this.retryOpts, ...this.opts }
308
+ },
309
+ onRetry.bind(this)
310
+ )
311
+
312
+ function onRetry (err) {
313
+ if (err != null || this.aborted || isDisturbed(this.opts.body)) {
314
+ return this.handler.onError(err)
315
+ }
316
+
317
+ if (this.start !== 0) {
318
+ this.opts = {
319
+ ...this.opts,
320
+ headers: {
321
+ ...this.opts.headers,
322
+ range: `bytes=${this.start}-${this.end ?? ''}`
323
+ }
324
+ }
325
+ }
326
+
327
+ try {
328
+ this.dispatch(this.opts, this)
329
+ } catch (err) {
330
+ this.handler.onError(err)
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ module.exports = RetryHandler
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "5.27.2",
3
+ "version": "5.28.0",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
@@ -118,6 +118,7 @@
118
118
  "jsdom": "^22.1.0",
119
119
  "jsfuzz": "^1.0.15",
120
120
  "mocha": "^10.0.0",
121
+ "mockttp": "^3.9.2",
121
122
  "p-timeout": "^3.2.0",
122
123
  "pre-commit": "^1.2.2",
123
124
  "proxy": "^1.0.2",
package/types/client.d.ts CHANGED
@@ -77,7 +77,7 @@ export declare namespace Client {
77
77
  */
78
78
  allowH2?: boolean;
79
79
  /**
80
- * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overriden by a SETTINGS remote frame.
80
+ * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame.
81
81
  * @default 100
82
82
  */
83
83
  maxConcurrentStreams?: number
@@ -211,7 +211,7 @@ declare namespace Dispatcher {
211
211
  /** Invoked when request is upgraded either due to a `Upgrade` header or `CONNECT` method. */
212
212
  onUpgrade?(statusCode: number, headers: Buffer[] | string[] | null, socket: Duplex): void;
213
213
  /** Invoked when statusCode and headers have been received. May be invoked multiple times due to 1xx informational headers. */
214
- onHeaders?(statusCode: number, headers: Buffer[] | string[] | null, resume: () => void): boolean;
214
+ onHeaders?(statusCode: number, headers: Buffer[] | string[] | null, resume: () => void, statusText: string): boolean;
215
215
  /** Invoked when response payload data is received. */
216
216
  onData?(chunk: Buffer): boolean;
217
217
  /** Invoked when response payload and trailers have been received and the request has completed. */
package/types/index.d.ts CHANGED
@@ -14,6 +14,7 @@ import MockPool from'./mock-pool'
14
14
  import MockAgent from'./mock-agent'
15
15
  import mockErrors from'./mock-errors'
16
16
  import ProxyAgent from'./proxy-agent'
17
+ import RetryHandler from'./retry-handler'
17
18
  import { request, pipeline, stream, connect, upgrade } from './api'
18
19
 
19
20
  export * from './cookies'
@@ -27,7 +28,7 @@ export * from './content-type'
27
28
  export * from './cache'
28
29
  export { Interceptable } from './mock-interceptor'
29
30
 
30
- export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, RedirectHandler, DecoratorHandler }
31
+ export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler }
31
32
  export default Undici
32
33
 
33
34
  declare namespace Undici {
@@ -35,6 +36,7 @@ declare namespace Undici {
35
36
  var Pool: typeof import('./pool').default;
36
37
  var RedirectHandler: typeof import ('./handlers').RedirectHandler
37
38
  var DecoratorHandler: typeof import ('./handlers').DecoratorHandler
39
+ var RetryHandler: typeof import ('./retry-handler').default
38
40
  var createRedirectInterceptor: typeof import ('./interceptors').createRedirectInterceptor
39
41
  var BalancedPool: typeof import('./balanced-pool').default;
40
42
  var Client: typeof import('./client').default;
@@ -0,0 +1,116 @@
1
+ import Dispatcher from "./dispatcher";
2
+
3
+ export default RetryHandler;
4
+
5
+ declare class RetryHandler implements Dispatcher.DispatchHandlers {
6
+ constructor(
7
+ options: Dispatcher.DispatchOptions & {
8
+ retryOptions?: RetryHandler.RetryOptions;
9
+ },
10
+ retryHandlers: RetryHandler.RetryHandlers
11
+ );
12
+ }
13
+
14
+ declare namespace RetryHandler {
15
+ export type RetryState = { counter: number; currentTimeout: number };
16
+
17
+ export type RetryContext = {
18
+ state: RetryState;
19
+ opts: Dispatcher.DispatchOptions & {
20
+ retryOptions?: RetryHandler.RetryOptions;
21
+ };
22
+ }
23
+
24
+ export type OnRetryCallback = (result?: Error | null) => void;
25
+
26
+ export type RetryCallback = (
27
+ err: Error,
28
+ context: {
29
+ state: RetryState;
30
+ opts: Dispatcher.DispatchOptions & {
31
+ retryOptions?: RetryHandler.RetryOptions;
32
+ };
33
+ },
34
+ callback: OnRetryCallback
35
+ ) => number | null;
36
+
37
+ export interface RetryOptions {
38
+ /**
39
+ * Callback to be invoked on every retry iteration.
40
+ * It receives the error, current state of the retry object and the options object
41
+ * passed when instantiating the retry handler.
42
+ *
43
+ * @type {RetryCallback}
44
+ * @memberof RetryOptions
45
+ */
46
+ retry?: RetryCallback;
47
+ /**
48
+ * Maximum number of retries to allow.
49
+ *
50
+ * @type {number}
51
+ * @memberof RetryOptions
52
+ * @default 5
53
+ */
54
+ maxRetries?: number;
55
+ /**
56
+ * Max number of milliseconds allow between retries
57
+ *
58
+ * @type {number}
59
+ * @memberof RetryOptions
60
+ * @default 30000
61
+ */
62
+ maxTimeout?: number;
63
+ /**
64
+ * Initial number of milliseconds to wait before retrying for the first time.
65
+ *
66
+ * @type {number}
67
+ * @memberof RetryOptions
68
+ * @default 500
69
+ */
70
+ minTimeout?: number;
71
+ /**
72
+ * Factior to multiply the timeout factor between retries.
73
+ *
74
+ * @type {number}
75
+ * @memberof RetryOptions
76
+ * @default 2
77
+ */
78
+ timeoutFactor?: number;
79
+ /**
80
+ * It enables to automatically infer timeout between retries based on the `Retry-After` header.
81
+ *
82
+ * @type {boolean}
83
+ * @memberof RetryOptions
84
+ * @default true
85
+ */
86
+ retryAfter?: boolean;
87
+ /**
88
+ * HTTP methods to retry.
89
+ *
90
+ * @type {Dispatcher.HttpMethod[]}
91
+ * @memberof RetryOptions
92
+ * @default ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'],
93
+ */
94
+ methods?: Dispatcher.HttpMethod[];
95
+ /**
96
+ * Error codes to be retried. e.g. `ECONNRESET`, `ENOTFOUND`, `ETIMEDOUT`, `ECONNREFUSED`, etc.
97
+ *
98
+ * @type {string[]}
99
+ * @default ['ECONNRESET','ECONNREFUSED','ENOTFOUND','ENETDOWN','ENETUNREACH','EHOSTDOWN','EHOSTUNREACH','EPIPE']
100
+ */
101
+ errorCodes?: string[];
102
+ /**
103
+ * HTTP status codes to be retried.
104
+ *
105
+ * @type {number[]}
106
+ * @memberof RetryOptions
107
+ * @default [500, 502, 503, 504, 429],
108
+ */
109
+ statusCodes?: number[];
110
+ }
111
+
112
+ export interface RetryHandlers {
113
+ dispatch: Dispatcher["dispatch"];
114
+ handler: Dispatcher.DispatchHandlers;
115
+ }
116
+ }