| const assert = require('assert') | |
| const { kRetryHandlerDefaultRetry } = require('../core/symbols') | |
| const { RequestRetryError } = require('../core/errors') | |
| const { isDisturbed, parseHeaders, parseRangeHeader } = require('../core/util') | |
| function calculateRetryAfterHeader (retryAfter) { | |
| const current = Date.now() | |
| const diff = new Date(retryAfter).getTime() - current | |
| return diff | |
| } | |
| class RetryHandler { | |
| constructor (opts, handlers) { | |
| const { retryOptions, ...dispatchOpts } = opts | |
| const { | |
| // Retry scoped | |
| retry: retryFn, | |
| maxRetries, | |
| maxTimeout, | |
| minTimeout, | |
| timeoutFactor, | |
| // Response scoped | |
| methods, | |
| errorCodes, | |
| retryAfter, | |
| statusCodes | |
| } = retryOptions ?? {} | |
| this.dispatch = handlers.dispatch | |
| this.handler = handlers.handler | |
| this.opts = dispatchOpts | |
| this.abort = null | |
| this.aborted = false | |
| this.retryOpts = { | |
| retry: retryFn ?? RetryHandler[kRetryHandlerDefaultRetry], | |
| retryAfter: retryAfter ?? true, | |
| maxTimeout: maxTimeout ?? 30 * 1000, // 30s, | |
| timeout: 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' | |
| ] | |
| } | |
| this.retryCount = 0 | |
| this.start = 0 | |
| this.end = null | |
| this.etag = null | |
| this.resume = null | |
| // Handle possible onConnect duplication | |
| this.handler.onConnect(reason => { | |
| this.aborted = true | |
| if (this.abort) { | |
| this.abort(reason) | |
| } else { | |
| this.reason = reason | |
| } | |
| }) | |
| } | |
| onRequestSent () { | |
| if (this.handler.onRequestSent) { | |
| this.handler.onRequestSent() | |
| } | |
| } | |
| onUpgrade (statusCode, headers, socket) { | |
| if (this.handler.onUpgrade) { | |
| this.handler.onUpgrade(statusCode, headers, socket) | |
| } | |
| } | |
| onConnect (abort) { | |
| if (this.aborted) { | |
| abort(this.reason) | |
| } else { | |
| this.abort = abort | |
| } | |
| } | |
| onBodySent (chunk) { | |
| if (this.handler.onBodySent) return this.handler.onBodySent(chunk) | |
| } | |
| static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) { | |
| const { statusCode, code, headers } = err | |
| const { method, retryOptions } = opts | |
| const { | |
| maxRetries, | |
| timeout, | |
| maxTimeout, | |
| timeoutFactor, | |
| statusCodes, | |
| errorCodes, | |
| methods | |
| } = retryOptions | |
| let { counter, currentTimeout } = state | |
| currentTimeout = | |
| currentTimeout != null && currentTimeout > 0 ? currentTimeout : timeout | |
| // Any code that is not a Undici's originated and allowed to retry | |
| if ( | |
| code && | |
| code !== 'UND_ERR_REQ_RETRY' && | |
| code !== 'UND_ERR_SOCKET' && | |
| !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 != null && headers['retry-after'] | |
| if (retryAfterHeader) { | |
| retryAfterHeader = Number(retryAfterHeader) | |
| retryAfterHeader = isNaN(retryAfterHeader) | |
| ? calculateRetryAfterHeader(retryAfterHeader) | |
| : retryAfterHeader * 1e3 // Retry-After is in seconds | |
| } | |
| const retryTimeout = | |
| retryAfterHeader > 0 | |
| ? Math.min(retryAfterHeader, maxTimeout) | |
| : Math.min(currentTimeout * timeoutFactor ** counter, maxTimeout) | |
| state.currentTimeout = retryTimeout | |
| setTimeout(() => cb(null), retryTimeout) | |
| } | |
| onHeaders (statusCode, rawHeaders, resume, statusMessage) { | |
| const headers = parseHeaders(rawHeaders) | |
| this.retryCount += 1 | |
| if (statusCode >= 300) { | |
| this.abort( | |
| new RequestRetryError('Request failed', statusCode, { | |
| headers, | |
| count: this.retryCount | |
| }) | |
| ) | |
| return false | |
| } | |
| // Checkpoint for resume from where we left it | |
| if (this.resume != null) { | |
| this.resume = null | |
| if (statusCode !== 206) { | |
| return true | |
| } | |
| const contentRange = parseRangeHeader(headers['content-range']) | |
| // If no content range | |
| if (!contentRange) { | |
| this.abort( | |
| new RequestRetryError('Content-Range mismatch', statusCode, { | |
| headers, | |
| count: this.retryCount | |
| }) | |
| ) | |
| return false | |
| } | |
| // Let's start with a weak etag check | |
| if (this.etag != null && this.etag !== headers.etag) { | |
| this.abort( | |
| new RequestRetryError('ETag mismatch', statusCode, { | |
| headers, | |
| count: this.retryCount | |
| }) | |
| ) | |
| return false | |
| } | |
| const { start, size, end = size } = contentRange | |
| assert(this.start === start, 'content-range mismatch') | |
| assert(this.end == null || this.end === end, 'content-range mismatch') | |
| this.resume = resume | |
| return true | |
| } | |
| if (this.end == null) { | |
| if (statusCode === 206) { | |
| // First time we receive 206 | |
| const range = parseRangeHeader(headers['content-range']) | |
| if (range == null) { | |
| return this.handler.onHeaders( | |
| statusCode, | |
| rawHeaders, | |
| resume, | |
| statusMessage | |
| ) | |
| } | |
| const { start, size, end = size } = range | |
| assert( | |
| start != null && Number.isFinite(start) && this.start !== start, | |
| 'content-range mismatch' | |
| ) | |
| assert(Number.isFinite(start)) | |
| assert( | |
| end != null && Number.isFinite(end) && this.end !== 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) : null | |
| } | |
| assert(Number.isFinite(this.start)) | |
| assert( | |
| this.end == null || Number.isFinite(this.end), | |
| 'invalid content-length' | |
| ) | |
| this.resume = resume | |
| this.etag = headers.etag != null ? headers.etag : null | |
| return this.handler.onHeaders( | |
| statusCode, | |
| rawHeaders, | |
| resume, | |
| statusMessage | |
| ) | |
| } | |
| const err = new RequestRetryError('Request failed', statusCode, { | |
| headers, | |
| count: this.retryCount | |
| }) | |
| this.abort(err) | |
| return false | |
| } | |
| onData (chunk) { | |
| this.start += chunk.length | |
| return this.handler.onData(chunk) | |
| } | |
| onComplete (rawTrailers) { | |
| this.retryCount = 0 | |
| return this.handler.onComplete(rawTrailers) | |
| } | |
| onError (err) { | |
| if (this.aborted || isDisturbed(this.opts.body)) { | |
| return this.handler.onError(err) | |
| } | |
| this.retryOpts.retry( | |
| err, | |
| { | |
| state: { counter: this.retryCount++, currentTimeout: this.retryAfter }, | |
| opts: { retryOptions: this.retryOpts, ...this.opts } | |
| }, | |
| onRetry.bind(this) | |
| ) | |
| function onRetry (err) { | |
| if (err != null || this.aborted || isDisturbed(this.opts.body)) { | |
| return this.handler.onError(err) | |
| } | |
| if (this.start !== 0) { | |
| this.opts = { | |
| ...this.opts, | |
| headers: { | |
| ...this.opts.headers, | |
| range: `bytes=${this.start}-${this.end ?? ''}` | |
| } | |
| } | |
| } | |
| try { | |
| this.dispatch(this.opts, this) | |
| } catch (err) { | |
| this.handler.onError(err) | |
| } | |
| } | |
| } | |
| } | |
| module.exports = RetryHandler |