aboutsummaryrefslogtreecommitdiffstats
path: root/vanilla/node_modules/undici/lib/handler/retry-handler.js
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 21:34:48 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 21:34:48 -0800
commit76cb9c2a39d477a64824a985ade40507e3bbade1 (patch)
tree41e997aa9c6f538d3a136af61dae9424db2005a9 /vanilla/node_modules/undici/lib/handler/retry-handler.js
parent819a39a21ac992b1393244a4c283bbb125208c69 (diff)
downloadneko-76cb9c2a39d477a64824a985ade40507e3bbade1.tar.gz
neko-76cb9c2a39d477a64824a985ade40507e3bbade1.tar.bz2
neko-76cb9c2a39d477a64824a985ade40507e3bbade1.zip
feat(vanilla): add testing infrastructure and tests (NK-wjnczv)
Diffstat (limited to 'vanilla/node_modules/undici/lib/handler/retry-handler.js')
-rw-r--r--vanilla/node_modules/undici/lib/handler/retry-handler.js394
1 files changed, 394 insertions, 0 deletions
diff --git a/vanilla/node_modules/undici/lib/handler/retry-handler.js b/vanilla/node_modules/undici/lib/handler/retry-handler.js
new file mode 100644
index 0000000..1cbc789
--- /dev/null
+++ b/vanilla/node_modules/undici/lib/handler/retry-handler.js
@@ -0,0 +1,394 @@
+'use strict'
+const assert = require('node:assert')
+
+const { kRetryHandlerDefaultRetry } = require('../core/symbols')
+const { RequestRetryError } = require('../core/errors')
+const WrapHandler = require('./wrap-handler')
+const {
+ isDisturbed,
+ parseRangeHeader,
+ wrapRequestBody
+} = require('../core/util')
+
+function calculateRetryAfterHeader (retryAfter) {
+ const retryTime = new Date(retryAfter).getTime()
+ return isNaN(retryTime) ? 0 : retryTime - Date.now()
+}
+
+class RetryHandler {
+ constructor (opts, { dispatch, handler }) {
+ const { retryOptions, ...dispatchOpts } = opts
+ const {
+ // Retry scoped
+ retry: retryFn,
+ maxRetries,
+ maxTimeout,
+ minTimeout,
+ timeoutFactor,
+ // Response scoped
+ methods,
+ errorCodes,
+ retryAfter,
+ statusCodes,
+ throwOnError
+ } = retryOptions ?? {}
+
+ this.error = null
+ this.dispatch = dispatch
+ this.handler = WrapHandler.wrap(handler)
+ this.opts = { ...dispatchOpts, body: wrapRequestBody(opts.body) }
+ this.retryOpts = {
+ throwOnError: throwOnError ?? true,
+ retry: retryFn ?? RetryHandler[kRetryHandlerDefaultRetry],
+ retryAfter: retryAfter ?? true,
+ maxTimeout: maxTimeout ?? 30 * 1000, // 30s,
+ minTimeout: minTimeout ?? 500, // .5s
+ timeoutFactor: timeoutFactor ?? 2,
+ maxRetries: maxRetries ?? 5,
+ // What errors we should retry
+ methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'],
+ // Indicates which errors to retry
+ statusCodes: statusCodes ?? [500, 502, 503, 504, 429],
+ // List of errors to retry
+ errorCodes: errorCodes ?? [
+ 'ECONNRESET',
+ 'ECONNREFUSED',
+ 'ENOTFOUND',
+ 'ENETDOWN',
+ 'ENETUNREACH',
+ 'EHOSTDOWN',
+ 'EHOSTUNREACH',
+ 'EPIPE',
+ 'UND_ERR_SOCKET'
+ ]
+ }
+
+ this.retryCount = 0
+ this.retryCountCheckpoint = 0
+ this.headersSent = false
+ this.start = 0
+ this.end = null
+ this.etag = null
+ }
+
+ onResponseStartWithRetry (controller, statusCode, headers, statusMessage, err) {
+ if (this.retryOpts.throwOnError) {
+ // Preserve old behavior for status codes that are not eligible for retry
+ if (this.retryOpts.statusCodes.includes(statusCode) === false) {
+ this.headersSent = true
+ this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
+ } else {
+ this.error = err
+ }
+
+ return
+ }
+
+ if (isDisturbed(this.opts.body)) {
+ this.headersSent = true
+ this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
+ return
+ }
+
+ function shouldRetry (passedErr) {
+ if (passedErr) {
+ this.headersSent = true
+ this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
+ controller.resume()
+ return
+ }
+
+ this.error = err
+ controller.resume()
+ }
+
+ controller.pause()
+ this.retryOpts.retry(
+ err,
+ {
+ state: { counter: this.retryCount },
+ opts: { retryOptions: this.retryOpts, ...this.opts }
+ },
+ shouldRetry.bind(this)
+ )
+ }
+
+ onRequestStart (controller, context) {
+ if (!this.headersSent) {
+ this.handler.onRequestStart?.(controller, context)
+ }
+ }
+
+ onRequestUpgrade (controller, statusCode, headers, socket) {
+ this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
+ }
+
+ static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) {
+ const { statusCode, code, headers } = err
+ const { method, retryOptions } = opts
+ const {
+ maxRetries,
+ minTimeout,
+ maxTimeout,
+ timeoutFactor,
+ statusCodes,
+ errorCodes,
+ methods
+ } = retryOptions
+ const { counter } = state
+
+ // Any code that is not a Undici's originated and allowed to retry
+ if (code && code !== 'UND_ERR_REQ_RETRY' && !errorCodes.includes(code)) {
+ cb(err)
+ return
+ }
+
+ // If a set of method are provided and the current method is not in the list
+ if (Array.isArray(methods) && !methods.includes(method)) {
+ cb(err)
+ return
+ }
+
+ // If a set of status code are provided and the current status code is not in the list
+ if (
+ statusCode != null &&
+ Array.isArray(statusCodes) &&
+ !statusCodes.includes(statusCode)
+ ) {
+ cb(err)
+ return
+ }
+
+ // If we reached the max number of retries
+ if (counter > maxRetries) {
+ cb(err)
+ return
+ }
+
+ let retryAfterHeader = headers?.['retry-after']
+ if (retryAfterHeader) {
+ retryAfterHeader = Number(retryAfterHeader)
+ retryAfterHeader = Number.isNaN(retryAfterHeader)
+ ? calculateRetryAfterHeader(headers['retry-after'])
+ : retryAfterHeader * 1e3 // Retry-After is in seconds
+ }
+
+ const retryTimeout =
+ retryAfterHeader > 0
+ ? Math.min(retryAfterHeader, maxTimeout)
+ : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout)
+
+ setTimeout(() => cb(null), retryTimeout)
+ }
+
+ onResponseStart (controller, statusCode, headers, statusMessage) {
+ this.error = null
+ this.retryCount += 1
+
+ if (statusCode >= 300) {
+ const err = new RequestRetryError('Request failed', statusCode, {
+ headers,
+ data: {
+ count: this.retryCount
+ }
+ })
+
+ this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
+ return
+ }
+
+ // Checkpoint for resume from where we left it
+ if (this.headersSent) {
+ // Only Partial Content 206 supposed to provide Content-Range,
+ // any other status code that partially consumed the payload
+ // should not be retried because it would result in downstream
+ // wrongly concatenate multiple responses.
+ if (statusCode !== 206 && (this.start > 0 || statusCode !== 200)) {
+ throw new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
+ headers,
+ data: { count: this.retryCount }
+ })
+ }
+
+ const contentRange = parseRangeHeader(headers['content-range'])
+ // If no content range
+ if (!contentRange) {
+ // We always throw here as we want to indicate that we entred unexpected path
+ throw new RequestRetryError('Content-Range mismatch', statusCode, {
+ headers,
+ data: { count: this.retryCount }
+ })
+ }
+
+ // Let's start with a weak etag check
+ if (this.etag != null && this.etag !== headers.etag) {
+ // We always throw here as we want to indicate that we entred unexpected path
+ throw new RequestRetryError('ETag mismatch', statusCode, {
+ headers,
+ data: { count: this.retryCount }
+ })
+ }
+
+ const { start, size, end = size ? size - 1 : null } = contentRange
+
+ assert(this.start === start, 'content-range mismatch')
+ assert(this.end == null || this.end === end, 'content-range mismatch')
+
+ return
+ }
+
+ if (this.end == null) {
+ if (statusCode === 206) {
+ // First time we receive 206
+ const range = parseRangeHeader(headers['content-range'])
+
+ if (range == null) {
+ this.headersSent = true
+ this.handler.onResponseStart?.(
+ controller,
+ statusCode,
+ headers,
+ statusMessage
+ )
+ return
+ }
+
+ const { start, size, end = size ? size - 1 : null } = range
+ assert(
+ start != null && Number.isFinite(start),
+ 'content-range mismatch'
+ )
+ assert(end != null && Number.isFinite(end), 'invalid content-length')
+
+ this.start = start
+ this.end = end
+ }
+
+ // We make our best to checkpoint the body for further range headers
+ if (this.end == null) {
+ const contentLength = headers['content-length']
+ this.end = contentLength != null ? Number(contentLength) - 1 : null
+ }
+
+ assert(Number.isFinite(this.start))
+ assert(
+ this.end == null || Number.isFinite(this.end),
+ 'invalid content-length'
+ )
+
+ this.resume = true
+ this.etag = headers.etag != null ? headers.etag : null
+
+ // Weak etags are not useful for comparison nor cache
+ // for instance not safe to assume if the response is byte-per-byte
+ // equal
+ if (
+ this.etag != null &&
+ this.etag[0] === 'W' &&
+ this.etag[1] === '/'
+ ) {
+ this.etag = null
+ }
+
+ this.headersSent = true
+ this.handler.onResponseStart?.(
+ controller,
+ statusCode,
+ headers,
+ statusMessage
+ )
+ } else {
+ throw new RequestRetryError('Request failed', statusCode, {
+ headers,
+ data: { count: this.retryCount }
+ })
+ }
+ }
+
+ onResponseData (controller, chunk) {
+ if (this.error) {
+ return
+ }
+
+ this.start += chunk.length
+
+ this.handler.onResponseData?.(controller, chunk)
+ }
+
+ onResponseEnd (controller, trailers) {
+ if (this.error && this.retryOpts.throwOnError) {
+ throw this.error
+ }
+
+ if (!this.error) {
+ this.retryCount = 0
+ return this.handler.onResponseEnd?.(controller, trailers)
+ }
+
+ this.retry(controller)
+ }
+
+ retry (controller) {
+ if (this.start !== 0) {
+ const headers = { range: `bytes=${this.start}-${this.end ?? ''}` }
+
+ // Weak etag check - weak etags will make comparison algorithms never match
+ if (this.etag != null) {
+ headers['if-match'] = this.etag
+ }
+
+ this.opts = {
+ ...this.opts,
+ headers: {
+ ...this.opts.headers,
+ ...headers
+ }
+ }
+ }
+
+ try {
+ this.retryCountCheckpoint = this.retryCount
+ this.dispatch(this.opts, this)
+ } catch (err) {
+ this.handler.onResponseError?.(controller, err)
+ }
+ }
+
+ onResponseError (controller, err) {
+ if (controller?.aborted || isDisturbed(this.opts.body)) {
+ this.handler.onResponseError?.(controller, err)
+ return
+ }
+
+ function shouldRetry (returnedErr) {
+ if (!returnedErr) {
+ this.retry(controller)
+ return
+ }
+
+ this.handler?.onResponseError?.(controller, returnedErr)
+ }
+
+ // We reconcile in case of a mix between network errors
+ // and server error response
+ if (this.retryCount - this.retryCountCheckpoint > 0) {
+ // We count the difference between the last checkpoint and the current retry count
+ this.retryCount =
+ this.retryCountCheckpoint +
+ (this.retryCount - this.retryCountCheckpoint)
+ } else {
+ this.retryCount += 1
+ }
+
+ this.retryOpts.retry(
+ err,
+ {
+ state: { counter: this.retryCount },
+ opts: { retryOptions: this.retryOpts, ...this.opts }
+ },
+ shouldRetry.bind(this)
+ )
+ }
+}
+
+module.exports = RetryHandler