codekingpro/portable-devtools
114k
1'use strict'
2const assert = require('node:assert')
3
4const { kRetryHandlerDefaultRetry } = require('../core/symbols')
5const { RequestRetryError } = require('../core/errors')
6const {
7 isDisturbed,
8 parseHeaders,
9 parseRangeHeader,
10 wrapRequestBody
11} = require('../core/util')
12
13function calculateRetryAfterHeader (retryAfter) {
14 const current = Date.now()
15 return new Date(retryAfter).getTime() - current
16}
17
18class RetryHandler {
19 constructor (opts, handlers) {
20 const { retryOptions, ...dispatchOpts } = opts
21 const {
22 // Retry scoped
23 retry: retryFn,
24 maxRetries,
25 maxTimeout,
26 minTimeout,
27 timeoutFactor,
28 // Response scoped
29 methods,
30 errorCodes,
31 retryAfter,
32 statusCodes
33 } = retryOptions ?? {}
34
35 this.dispatch = handlers.dispatch
36 this.handler = handlers.handler
37 this.opts = { ...dispatchOpts, body: wrapRequestBody(opts.body) }
38 this.abort = null
39 this.aborted = false
40 this.retryOpts = {
41 retry: retryFn ?? RetryHandler[kRetryHandlerDefaultRetry],
42 retryAfter: retryAfter ?? true,
43 maxTimeout: maxTimeout ?? 30 * 1000, // 30s,
44 minTimeout: minTimeout ?? 500, // .5s
45 timeoutFactor: timeoutFactor ?? 2,
46 maxRetries: maxRetries ?? 5,
47 // What errors we should retry
48 methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'],
49 // Indicates which errors to retry
50 statusCodes: statusCodes ?? [500, 502, 503, 504, 429],
51 // List of errors to retry
52 errorCodes: errorCodes ?? [
53 'ECONNRESET',
54 'ECONNREFUSED',
55 'ENOTFOUND',
56 'ENETDOWN',
57 'ENETUNREACH',
58 'EHOSTDOWN',
59 'EHOSTUNREACH',
60 'EPIPE',
61 'UND_ERR_SOCKET'
62 ]
63 }
64
65 this.retryCount = 0
66 this.retryCountCheckpoint = 0
67 this.start = 0
68 this.end = null
69 this.etag = null
70 this.resume = null
71
72 // Handle possible onConnect duplication
73 this.handler.onConnect(reason => {
74 this.aborted = true
75 if (this.abort) {
76 this.abort(reason)
77 } else {
78 this.reason = reason
79 }
80 })
81 }
82
83 onRequestSent () {
84 if (this.handler.onRequestSent) {
85 this.handler.onRequestSent()
86 }
87 }
88
89 onUpgrade (statusCode, headers, socket) {
90 if (this.handler.onUpgrade) {
91 this.handler.onUpgrade(statusCode, headers, socket)
92 }
93 }
94
95 onConnect (abort) {
96 if (this.aborted) {
97 abort(this.reason)
98 } else {
99 this.abort = abort
100 }
101 }
102
103 onBodySent (chunk) {
104 if (this.handler.onBodySent) return this.handler.onBodySent(chunk)
105 }
106
107 static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) {
108 const { statusCode, code, headers } = err
109 const { method, retryOptions } = opts
110 const {
111 maxRetries,
112 minTimeout,
113 maxTimeout,
114 timeoutFactor,
115 statusCodes,
116 errorCodes,
117 methods
118 } = retryOptions
119 const { counter } = state
120
121 // Any code that is not a Undici's originated and allowed to retry
122 if (code && code !== 'UND_ERR_REQ_RETRY' && !errorCodes.includes(code)) {
123 cb(err)
124 return
125 }
126
127 // If a set of method are provided and the current method is not in the list
128 if (Array.isArray(methods) && !methods.includes(method)) {
129 cb(err)
130 return
131 }
132
133 // If a set of status code are provided and the current status code is not in the list
134 if (
135 statusCode != null &&
136 Array.isArray(statusCodes) &&
137 !statusCodes.includes(statusCode)
138 ) {
139 cb(err)
140 return
141 }
142
143 // If we reached the max number of retries
144 if (counter > maxRetries) {
145 cb(err)
146 return
147 }
148
149 let retryAfterHeader = headers?.['retry-after']
150 if (retryAfterHeader) {
151 retryAfterHeader = Number(retryAfterHeader)
152 retryAfterHeader = Number.isNaN(retryAfterHeader)
153 ? calculateRetryAfterHeader(retryAfterHeader)
154 : retryAfterHeader * 1e3 // Retry-After is in seconds
155 }
156
157 const retryTimeout =
158 retryAfterHeader > 0
159 ? Math.min(retryAfterHeader, maxTimeout)
160 : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout)
161
162 setTimeout(() => cb(null), retryTimeout)
163 }
164
165 onHeaders (statusCode, rawHeaders, resume, statusMessage) {
166 const headers = parseHeaders(rawHeaders)
167
168 this.retryCount += 1
169
170 if (statusCode >= 300) {
171 if (this.retryOpts.statusCodes.includes(statusCode) === false) {
172 return this.handler.onHeaders(
173 statusCode,
174 rawHeaders,
175 resume,
176 statusMessage
177 )
178 } else {
179 this.abort(
180 new RequestRetryError('Request failed', statusCode, {
181 headers,
182 data: {
183 count: this.retryCount
184 }
185 })
186 )
187 return false
188 }
189 }
190
191 // Checkpoint for resume from where we left it
192 if (this.resume != null) {
193 this.resume = null
194
195 // Only Partial Content 206 supposed to provide Content-Range,
196 // any other status code that partially consumed the payload
197 // should not be retry because it would result in downstream
198 // wrongly concatanete multiple responses.
199 if (statusCode !== 206 && (this.start > 0 || statusCode !== 200)) {
200 this.abort(
201 new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
202 headers,
203 data: { count: this.retryCount }
204 })
205 )
206 return false
207 }
208
209 const contentRange = parseRangeHeader(headers['content-range'])
210 // If no content range
211 if (!contentRange) {
212 this.abort(
213 new RequestRetryError('Content-Range mismatch', statusCode, {
214 headers,
215 data: { count: this.retryCount }
216 })
217 )
218 return false
219 }
220
221 // Let's start with a weak etag check
222 if (this.etag != null && this.etag !== headers.etag) {
223 this.abort(
224 new RequestRetryError('ETag mismatch', statusCode, {
225 headers,
226 data: { count: this.retryCount }
227 })
228 )
229 return false
230 }
231
232 const { start, size, end = size - 1 } = contentRange
233
234 assert(this.start === start, 'content-range mismatch')
235 assert(this.end == null || this.end === end, 'content-range mismatch')
236
237 this.resume = resume
238 return true
239 }
240
241 if (this.end == null) {
242 if (statusCode === 206) {
243 // First time we receive 206
244 const range = parseRangeHeader(headers['content-range'])
245
246 if (range == null) {
247 return this.handler.onHeaders(
248 statusCode,
249 rawHeaders,
250 resume,
251 statusMessage
252 )
253 }
254
255 const { start, size, end = size - 1 } = range
256 assert(
257 start != null && Number.isFinite(start),
258 'content-range mismatch'
259 )
260 assert(end != null && Number.isFinite(end), 'invalid content-length')
261
262 this.start = start
263 this.end = end
264 }
265
266 // We make our best to checkpoint the body for further range headers
267 if (this.end == null) {
268 const contentLength = headers['content-length']
269 this.end = contentLength != null ? Number(contentLength) - 1 : null
270 }
271
272 assert(Number.isFinite(this.start))
273 assert(
274 this.end == null || Number.isFinite(this.end),
275 'invalid content-length'
276 )
277
278 this.resume = resume
279 this.etag = headers.etag != null ? headers.etag : null
280
281 // Weak etags are not useful for comparison nor cache
282 // for instance not safe to assume if the response is byte-per-byte
283 // equal
284 if (this.etag != null && this.etag.startsWith('W/')) {
285 this.etag = null
286 }
287
288 return this.handler.onHeaders(
289 statusCode,
290 rawHeaders,
291 resume,
292 statusMessage
293 )
294 }
295
296 const err = new RequestRetryError('Request failed', statusCode, {
297 headers,
298 data: { count: this.retryCount }
299 })
300
301 this.abort(err)
302
303 return false
304 }
305
306 onData (chunk) {
307 this.start += chunk.length
308
309 return this.handler.onData(chunk)
310 }
311
312 onComplete (rawTrailers) {
313 this.retryCount = 0
314 return this.handler.onComplete(rawTrailers)
315 }
316
317 onError (err) {
318 if (this.aborted || isDisturbed(this.opts.body)) {
319 return this.handler.onError(err)
320 }
321
322 // We reconcile in case of a mix between network errors
323 // and server error response
324 if (this.retryCount - this.retryCountCheckpoint > 0) {
325 // We count the difference between the last checkpoint and the current retry count
326 this.retryCount =
327 this.retryCountCheckpoint +
328 (this.retryCount - this.retryCountCheckpoint)
329 } else {
330 this.retryCount += 1
331 }
332
333 this.retryOpts.retry(
334 err,
335 {
336 state: { counter: this.retryCount },
337 opts: { retryOptions: this.retryOpts, ...this.opts }
338 },
339 onRetry.bind(this)
340 )
341
342 function onRetry (err) {
343 if (err != null || this.aborted || isDisturbed(this.opts.body)) {
344 return this.handler.onError(err)
345 }
346
347 if (this.start !== 0) {
348 const headers = { range: `bytes=${this.start}-${this.end ?? ''}` }
349
350 // Weak etag check - weak etags will make comparison algorithms never match
351 if (this.etag != null) {
352 headers['if-match'] = this.etag
353 }
354
355 this.opts = {
356 ...this.opts,
357 headers: {
358 ...this.opts.headers,
359 ...headers
360 }
361 }
362 }
363
364 try {
365 this.retryCountCheckpoint = this.retryCount
366 this.dispatch(this.opts, this)
367 } catch (err) {
368 this.handler.onError(err)
369 }
370 }
371 }
372}
373
374module.exports = RetryHandler
375 