retry-handler.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. 'use strict'
  2. const assert = require('node:assert')
  3. const { kRetryHandlerDefaultRetry } = require('../core/symbols')
  4. const { RequestRetryError } = require('../core/errors')
  5. const WrapHandler = require('./wrap-handler')
  6. const {
  7. isDisturbed,
  8. parseRangeHeader,
  9. wrapRequestBody
  10. } = require('../core/util')
  11. function calculateRetryAfterHeader (retryAfter) {
  12. const retryTime = new Date(retryAfter).getTime()
  13. return isNaN(retryTime) ? 0 : retryTime - Date.now()
  14. }
  15. class RetryHandler {
  16. constructor (opts, { dispatch, handler }) {
  17. const { retryOptions, ...dispatchOpts } = opts
  18. const {
  19. // Retry scoped
  20. retry: retryFn,
  21. maxRetries,
  22. maxTimeout,
  23. minTimeout,
  24. timeoutFactor,
  25. // Response scoped
  26. methods,
  27. errorCodes,
  28. retryAfter,
  29. statusCodes,
  30. throwOnError
  31. } = retryOptions ?? {}
  32. this.error = null
  33. this.dispatch = dispatch
  34. this.handler = WrapHandler.wrap(handler)
  35. this.opts = { ...dispatchOpts, body: wrapRequestBody(opts.body) }
  36. this.retryOpts = {
  37. throwOnError: throwOnError ?? true,
  38. retry: retryFn ?? RetryHandler[kRetryHandlerDefaultRetry],
  39. retryAfter: retryAfter ?? true,
  40. maxTimeout: maxTimeout ?? 30 * 1000, // 30s,
  41. minTimeout: minTimeout ?? 500, // .5s
  42. timeoutFactor: timeoutFactor ?? 2,
  43. maxRetries: maxRetries ?? 5,
  44. // What errors we should retry
  45. methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'],
  46. // Indicates which errors to retry
  47. statusCodes: statusCodes ?? [500, 502, 503, 504, 429],
  48. // List of errors to retry
  49. errorCodes: errorCodes ?? [
  50. 'ECONNRESET',
  51. 'ECONNREFUSED',
  52. 'ENOTFOUND',
  53. 'ENETDOWN',
  54. 'ENETUNREACH',
  55. 'EHOSTDOWN',
  56. 'EHOSTUNREACH',
  57. 'EPIPE',
  58. 'UND_ERR_SOCKET'
  59. ]
  60. }
  61. this.retryCount = 0
  62. this.retryCountCheckpoint = 0
  63. this.headersSent = false
  64. this.start = 0
  65. this.end = null
  66. this.etag = null
  67. }
  68. onResponseStartWithRetry (controller, statusCode, headers, statusMessage, err) {
  69. if (this.retryOpts.throwOnError) {
  70. // Preserve old behavior for status codes that are not eligible for retry
  71. if (this.retryOpts.statusCodes.includes(statusCode) === false) {
  72. this.headersSent = true
  73. this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
  74. } else {
  75. this.error = err
  76. }
  77. return
  78. }
  79. if (isDisturbed(this.opts.body)) {
  80. this.headersSent = true
  81. this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
  82. return
  83. }
  84. function shouldRetry (passedErr) {
  85. if (passedErr) {
  86. this.headersSent = true
  87. this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
  88. controller.resume()
  89. return
  90. }
  91. this.error = err
  92. controller.resume()
  93. }
  94. controller.pause()
  95. this.retryOpts.retry(
  96. err,
  97. {
  98. state: { counter: this.retryCount },
  99. opts: { retryOptions: this.retryOpts, ...this.opts }
  100. },
  101. shouldRetry.bind(this)
  102. )
  103. }
  104. onRequestStart (controller, context) {
  105. if (!this.headersSent) {
  106. this.handler.onRequestStart?.(controller, context)
  107. }
  108. }
  109. onRequestUpgrade (controller, statusCode, headers, socket) {
  110. this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
  111. }
  112. static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) {
  113. const { statusCode, code, headers } = err
  114. const { method, retryOptions } = opts
  115. const {
  116. maxRetries,
  117. minTimeout,
  118. maxTimeout,
  119. timeoutFactor,
  120. statusCodes,
  121. errorCodes,
  122. methods
  123. } = retryOptions
  124. const { counter } = state
  125. // Any code that is not a Undici's originated and allowed to retry
  126. if (code && code !== 'UND_ERR_REQ_RETRY' && !errorCodes.includes(code)) {
  127. cb(err)
  128. return
  129. }
  130. // If a set of method are provided and the current method is not in the list
  131. if (Array.isArray(methods) && !methods.includes(method)) {
  132. cb(err)
  133. return
  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. // If we reached the max number of retries
  145. if (counter > maxRetries) {
  146. cb(err)
  147. return
  148. }
  149. let retryAfterHeader = headers?.['retry-after']
  150. if (retryAfterHeader) {
  151. retryAfterHeader = Number(retryAfterHeader)
  152. retryAfterHeader = Number.isNaN(retryAfterHeader)
  153. ? calculateRetryAfterHeader(headers['retry-after'])
  154. : retryAfterHeader * 1e3 // Retry-After is in seconds
  155. }
  156. const retryTimeout =
  157. retryAfterHeader > 0
  158. ? Math.min(retryAfterHeader, maxTimeout)
  159. : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout)
  160. setTimeout(() => cb(null), retryTimeout)
  161. }
  162. onResponseStart (controller, statusCode, headers, statusMessage) {
  163. this.error = null
  164. this.retryCount += 1
  165. if (statusCode >= 300) {
  166. const err = new RequestRetryError('Request failed', statusCode, {
  167. headers,
  168. data: {
  169. count: this.retryCount
  170. }
  171. })
  172. this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
  173. return
  174. }
  175. // Checkpoint for resume from where we left it
  176. if (this.headersSent) {
  177. // Only Partial Content 206 supposed to provide Content-Range,
  178. // any other status code that partially consumed the payload
  179. // should not be retried because it would result in downstream
  180. // wrongly concatenate multiple responses.
  181. if (statusCode !== 206 && (this.start > 0 || statusCode !== 200)) {
  182. throw new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
  183. headers,
  184. data: { count: this.retryCount }
  185. })
  186. }
  187. const contentRange = parseRangeHeader(headers['content-range'])
  188. // If no content range
  189. if (!contentRange) {
  190. // We always throw here as we want to indicate that we entred unexpected path
  191. throw new RequestRetryError('Content-Range mismatch', statusCode, {
  192. headers,
  193. data: { count: this.retryCount }
  194. })
  195. }
  196. // Let's start with a weak etag check
  197. if (this.etag != null && this.etag !== headers.etag) {
  198. // We always throw here as we want to indicate that we entred unexpected path
  199. throw new RequestRetryError('ETag mismatch', statusCode, {
  200. headers,
  201. data: { count: this.retryCount }
  202. })
  203. }
  204. const { start, size, end = size ? size - 1 : null } = contentRange
  205. assert(this.start === start, 'content-range mismatch')
  206. assert(this.end == null || this.end === end, 'content-range mismatch')
  207. return
  208. }
  209. if (this.end == null) {
  210. if (statusCode === 206) {
  211. // First time we receive 206
  212. const range = parseRangeHeader(headers['content-range'])
  213. if (range == null) {
  214. this.headersSent = true
  215. this.handler.onResponseStart?.(
  216. controller,
  217. statusCode,
  218. headers,
  219. statusMessage
  220. )
  221. return
  222. }
  223. const { start, size, end = size ? size - 1 : null } = range
  224. assert(
  225. start != null && Number.isFinite(start),
  226. 'content-range mismatch'
  227. )
  228. assert(end != null && Number.isFinite(end), 'invalid content-length')
  229. this.start = start
  230. this.end = end
  231. }
  232. // We make our best to checkpoint the body for further range headers
  233. if (this.end == null) {
  234. const contentLength = headers['content-length']
  235. this.end = contentLength != null ? Number(contentLength) - 1 : null
  236. }
  237. assert(Number.isFinite(this.start))
  238. assert(
  239. this.end == null || Number.isFinite(this.end),
  240. 'invalid content-length'
  241. )
  242. this.resume = true
  243. this.etag = headers.etag != null ? headers.etag : null
  244. // Weak etags are not useful for comparison nor cache
  245. // for instance not safe to assume if the response is byte-per-byte
  246. // equal
  247. if (
  248. this.etag != null &&
  249. this.etag[0] === 'W' &&
  250. this.etag[1] === '/'
  251. ) {
  252. this.etag = null
  253. }
  254. this.headersSent = true
  255. this.handler.onResponseStart?.(
  256. controller,
  257. statusCode,
  258. headers,
  259. statusMessage
  260. )
  261. } else {
  262. throw new RequestRetryError('Request failed', statusCode, {
  263. headers,
  264. data: { count: this.retryCount }
  265. })
  266. }
  267. }
  268. onResponseData (controller, chunk) {
  269. if (this.error) {
  270. return
  271. }
  272. this.start += chunk.length
  273. this.handler.onResponseData?.(controller, chunk)
  274. }
  275. onResponseEnd (controller, trailers) {
  276. if (this.error && this.retryOpts.throwOnError) {
  277. throw this.error
  278. }
  279. if (!this.error) {
  280. this.retryCount = 0
  281. return this.handler.onResponseEnd?.(controller, trailers)
  282. }
  283. this.retry(controller)
  284. }
  285. retry (controller) {
  286. if (this.start !== 0) {
  287. const headers = { range: `bytes=${this.start}-${this.end ?? ''}` }
  288. // Weak etag check - weak etags will make comparison algorithms never match
  289. if (this.etag != null) {
  290. headers['if-match'] = this.etag
  291. }
  292. this.opts = {
  293. ...this.opts,
  294. headers: {
  295. ...this.opts.headers,
  296. ...headers
  297. }
  298. }
  299. }
  300. try {
  301. this.retryCountCheckpoint = this.retryCount
  302. this.dispatch(this.opts, this)
  303. } catch (err) {
  304. this.handler.onResponseError?.(controller, err)
  305. }
  306. }
  307. onResponseError (controller, err) {
  308. if (controller?.aborted || isDisturbed(this.opts.body)) {
  309. this.handler.onResponseError?.(controller, err)
  310. return
  311. }
  312. function shouldRetry (returnedErr) {
  313. if (!returnedErr) {
  314. this.retry(controller)
  315. return
  316. }
  317. this.handler?.onResponseError?.(controller, returnedErr)
  318. }
  319. // We reconcile in case of a mix between network errors
  320. // and server error response
  321. if (this.retryCount - this.retryCountCheckpoint > 0) {
  322. // We count the difference between the last checkpoint and the current retry count
  323. this.retryCount =
  324. this.retryCountCheckpoint +
  325. (this.retryCount - this.retryCountCheckpoint)
  326. } else {
  327. this.retryCount += 1
  328. }
  329. this.retryOpts.retry(
  330. err,
  331. {
  332. state: { counter: this.retryCount },
  333. opts: { retryOptions: this.retryOpts, ...this.opts }
  334. },
  335. shouldRetry.bind(this)
  336. )
  337. }
  338. }
  339. module.exports = RetryHandler