1 | const assert = require('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 | if (this.handler.onBodySent) 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
|
---|