retry-handler.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  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.headersSent = true
  88. this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
  89. controller.resume()
  90. return
  91. }
  92. this.error = err
  93. controller.resume()
  94. }
  95. controller.pause()
  96. this.retryOpts.retry(
  97. err,
  98. {
  99. state: { counter: this.retryCount },
  100. opts: { retryOptions: this.retryOpts, ...this.opts }
  101. },
  102. shouldRetry.bind(this)
  103. )
  104. }
  105. onRequestStart (controller, context) {
  106. if (!this.headersSent) {
  107. this.handler.onRequestStart?.(controller, context)
  108. }
  109. }
  110. onRequestUpgrade (controller, statusCode, headers, socket) {
  111. this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket)
  112. }
  113. static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) {
  114. const { statusCode, code, headers } = err
  115. const { method, retryOptions } = opts
  116. const {
  117. maxRetries,
  118. minTimeout,
  119. maxTimeout,
  120. timeoutFactor,
  121. statusCodes,
  122. errorCodes,
  123. methods
  124. } = retryOptions
  125. const { counter } = state
  126. // Any code that is not a Undici's originated and allowed to retry
  127. if (code && code !== 'UND_ERR_REQ_RETRY' && !errorCodes.includes(code)) {
  128. cb(err)
  129. return
  130. }
  131. // If a set of method are provided and the current method is not in the list
  132. if (Array.isArray(methods) && !methods.includes(method)) {
  133. cb(err)
  134. return
  135. }
  136. // If a set of status code are provided and the current status code is not in the list
  137. if (
  138. statusCode != null &&
  139. Array.isArray(statusCodes) &&
  140. !statusCodes.includes(statusCode)
  141. ) {
  142. cb(err)
  143. return
  144. }
  145. // If we reached the max number of retries
  146. if (counter > maxRetries) {
  147. cb(err)
  148. return
  149. }
  150. let retryAfterHeader = headers?.['retry-after']
  151. if (retryAfterHeader) {
  152. retryAfterHeader = Number(retryAfterHeader)
  153. retryAfterHeader = Number.isNaN(retryAfterHeader)
  154. ? calculateRetryAfterHeader(headers['retry-after'])
  155. : retryAfterHeader * 1e3 // Retry-After is in seconds
  156. }
  157. const retryTimeout =
  158. retryAfterHeader > 0
  159. ? Math.min(retryAfterHeader, maxTimeout)
  160. : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout)
  161. setTimeout(() => cb(null), retryTimeout)
  162. }
  163. onResponseStart (controller, statusCode, headers, statusMessage) {
  164. this.error = null
  165. this.retryCount += 1
  166. if (statusCode >= 300) {
  167. const err = new RequestRetryError('Request failed', statusCode, {
  168. headers,
  169. data: {
  170. count: this.retryCount
  171. }
  172. })
  173. this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
  174. return
  175. }
  176. // Checkpoint for resume from where we left it
  177. if (this.headersSent) {
  178. // Only Partial Content 206 supposed to provide Content-Range,
  179. // any other status code that partially consumed the payload
  180. // should not be retried because it would result in downstream
  181. // wrongly concatenate multiple responses.
  182. if (statusCode !== 206 && (this.start > 0 || statusCode !== 200)) {
  183. throw new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
  184. headers,
  185. data: { count: this.retryCount }
  186. })
  187. }
  188. const contentRange = parseRangeHeader(headers['content-range'])
  189. // If no content range
  190. if (!contentRange) {
  191. // We always throw here as we want to indicate that we entred unexpected path
  192. throw new RequestRetryError('Content-Range mismatch', statusCode, {
  193. headers,
  194. data: { count: this.retryCount }
  195. })
  196. }
  197. // Let's start with a weak etag check
  198. if (this.etag != null && this.etag !== headers.etag) {
  199. // We always throw here as we want to indicate that we entred unexpected path
  200. throw new RequestRetryError('ETag mismatch', statusCode, {
  201. headers,
  202. data: { count: this.retryCount }
  203. })
  204. }
  205. const { start, size, end = size ? size - 1 : null } = contentRange
  206. assert(this.start === start, 'content-range mismatch')
  207. assert(this.end == null || this.end === end, 'content-range mismatch')
  208. return
  209. }
  210. if (this.end == null) {
  211. if (statusCode === 206) {
  212. // First time we receive 206
  213. const range = parseRangeHeader(headers['content-range'])
  214. if (range == null) {
  215. this.headersSent = true
  216. this.handler.onResponseStart?.(
  217. controller,
  218. statusCode,
  219. headers,
  220. statusMessage
  221. )
  222. return
  223. }
  224. const { start, size, end = size ? size - 1 : null } = range
  225. assert(
  226. start != null && Number.isFinite(start),
  227. 'content-range mismatch'
  228. )
  229. assert(end != null && Number.isFinite(end), 'invalid content-length')
  230. this.start = start
  231. this.end = end
  232. }
  233. // We make our best to checkpoint the body for further range headers
  234. if (this.end == null) {
  235. const contentLength = headers['content-length']
  236. this.end = contentLength != null ? Number(contentLength) - 1 : null
  237. }
  238. assert(Number.isFinite(this.start))
  239. assert(
  240. this.end == null || Number.isFinite(this.end),
  241. 'invalid content-length'
  242. )
  243. this.resume = true
  244. this.etag = headers.etag != null ? headers.etag : null
  245. // Weak etags are not useful for comparison nor cache
  246. // for instance not safe to assume if the response is byte-per-byte
  247. // equal
  248. if (
  249. this.etag != null &&
  250. this.etag[0] === 'W' &&
  251. this.etag[1] === '/'
  252. ) {
  253. this.etag = null
  254. }
  255. this.headersSent = true
  256. this.handler.onResponseStart?.(
  257. controller,
  258. statusCode,
  259. headers,
  260. statusMessage
  261. )
  262. } else {
  263. throw new RequestRetryError('Request failed', statusCode, {
  264. headers,
  265. data: { count: this.retryCount }
  266. })
  267. }
  268. }
  269. onResponseData (controller, chunk) {
  270. if (this.error) {
  271. return
  272. }
  273. this.start += chunk.length
  274. this.handler.onResponseData?.(controller, chunk)
  275. }
  276. onResponseEnd (controller, trailers) {
  277. if (this.error && this.retryOpts.throwOnError) {
  278. throw this.error
  279. }
  280. if (!this.error) {
  281. this.retryCount = 0
  282. return this.handler.onResponseEnd?.(controller, trailers)
  283. }
  284. this.retry(controller)
  285. }
  286. retry (controller) {
  287. if (this.start !== 0) {
  288. const headers = { range: `bytes=${this.start}-${this.end ?? ''}` }
  289. // Weak etag check - weak etags will make comparison algorithms never match
  290. if (this.etag != null) {
  291. headers['if-match'] = this.etag
  292. }
  293. this.opts = {
  294. ...this.opts,
  295. headers: {
  296. ...this.opts.headers,
  297. ...headers
  298. }
  299. }
  300. }
  301. try {
  302. this.retryCountCheckpoint = this.retryCount
  303. this.dispatch(this.opts, this)
  304. } catch (err) {
  305. this.handler.onResponseError?.(controller, err)
  306. }
  307. }
  308. onResponseError (controller, err) {
  309. if (controller?.aborted || isDisturbed(this.opts.body)) {
  310. this.handler.onResponseError?.(controller, err)
  311. return
  312. }
  313. function shouldRetry (returnedErr) {
  314. if (!returnedErr) {
  315. this.retry(controller)
  316. return
  317. }
  318. this.handler?.onResponseError?.(controller, returnedErr)
  319. }
  320. // We reconcile in case of a mix between network errors
  321. // and server error response
  322. if (this.retryCount - this.retryCountCheckpoint > 0) {
  323. // We count the difference between the last checkpoint and the current retry count
  324. this.retryCount =
  325. this.retryCountCheckpoint +
  326. (this.retryCount - this.retryCountCheckpoint)
  327. } else {
  328. this.retryCount += 1
  329. }
  330. this.retryOpts.retry(
  331. err,
  332. {
  333. state: { counter: this.retryCount },
  334. opts: { retryOptions: this.retryOpts, ...this.opts }
  335. },
  336. shouldRetry.bind(this)
  337. )
  338. }
  339. }
  340. module.exports = RetryHandler