1const assert = require('assert')
2
3const { kRetryHandlerDefaultRetry } = require('../core/symbols')
4const { RequestRetryError } = require('../core/errors')
5const { isDisturbed, parseHeaders, parseRangeHeader } = require('../core/util')
6
7function calculateRetryAfterHeader (retryAfter) {
8  const current = Date.now()
9  const diff = new Date(retryAfter).getTime() - current
10
11  return diff
12}
13
14class 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
336module.exports = RetryHandler
337