Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
retry-handler.js375 linesDownload Raw Back to handler
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 
codekingpro/portable-devtools · Team Ai