retry-handler.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. 'use strict'
  2. const assert = require('node:assert')
  3. const { kRetryHandlerDefaultRetry } = require('../core/symbols')
  4. const { RequestRetryError } = require('../core/errors')
  5. const {
  6. isDisturbed,
  7. parseHeaders,
  8. parseRangeHeader,
  9. wrapRequestBody
  10. } = require('../core/util')
  11. function calculateRetryAfterHeader (retryAfter) {
  12. const current = Date.now()
  13. return new Date(retryAfter).getTime() - current
  14. }
  15. class RetryHandler {
  16. constructor (opts, handlers) {
  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. } = retryOptions ?? {}
  31. this.dispatch = handlers.dispatch
  32. this.handler = handlers.handler
  33. this.opts = { ...dispatchOpts, body: wrapRequestBody(opts.body) }
  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. minTimeout: 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. 'UND_ERR_SOCKET'
  58. ]
  59. }
  60. this.retryCount = 0
  61. this.retryCountCheckpoint = 0
  62. this.start = 0
  63. this.end = null
  64. this.etag = null
  65. this.resume = null
  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. onRequestSent () {
  77. if (this.handler.onRequestSent) {
  78. this.handler.onRequestSent()
  79. }
  80. }
  81. onUpgrade (statusCode, headers, socket) {
  82. if (this.handler.onUpgrade) {
  83. this.handler.onUpgrade(statusCode, headers, socket)
  84. }
  85. }
  86. onConnect (abort) {
  87. if (this.aborted) {
  88. abort(this.reason)
  89. } else {
  90. this.abort = abort
  91. }
  92. }
  93. onBodySent (chunk) {
  94. if (this.handler.onBodySent) return this.handler.onBodySent(chunk)
  95. }
  96. static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) {
  97. const { statusCode, code, headers } = err
  98. const { method, retryOptions } = opts
  99. const {
  100. maxRetries,
  101. minTimeout,
  102. maxTimeout,
  103. timeoutFactor,
  104. statusCodes,
  105. errorCodes,
  106. methods
  107. } = retryOptions
  108. const { counter } = state
  109. // Any code that is not a Undici's originated and allowed to retry
  110. if (code && code !== 'UND_ERR_REQ_RETRY' && !errorCodes.includes(code)) {
  111. cb(err)
  112. return
  113. }
  114. // If a set of method are provided and the current method is not in the list
  115. if (Array.isArray(methods) && !methods.includes(method)) {
  116. cb(err)
  117. return
  118. }
  119. // If a set of status code are provided and the current status code is not in the list
  120. if (
  121. statusCode != null &&
  122. Array.isArray(statusCodes) &&
  123. !statusCodes.includes(statusCode)
  124. ) {
  125. cb(err)
  126. return
  127. }
  128. // If we reached the max number of retries
  129. if (counter > maxRetries) {
  130. cb(err)
  131. return
  132. }
  133. let retryAfterHeader = headers?.['retry-after']
  134. if (retryAfterHeader) {
  135. retryAfterHeader = Number(retryAfterHeader)
  136. retryAfterHeader = Number.isNaN(retryAfterHeader)
  137. ? calculateRetryAfterHeader(retryAfterHeader)
  138. : retryAfterHeader * 1e3 // Retry-After is in seconds
  139. }
  140. const retryTimeout =
  141. retryAfterHeader > 0
  142. ? Math.min(retryAfterHeader, maxTimeout)
  143. : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout)
  144. setTimeout(() => cb(null), retryTimeout)
  145. }
  146. onHeaders (statusCode, rawHeaders, resume, statusMessage) {
  147. const headers = parseHeaders(rawHeaders)
  148. this.retryCount += 1
  149. if (statusCode >= 300) {
  150. if (this.retryOpts.statusCodes.includes(statusCode) === false) {
  151. return this.handler.onHeaders(
  152. statusCode,
  153. rawHeaders,
  154. resume,
  155. statusMessage
  156. )
  157. } else {
  158. this.abort(
  159. new RequestRetryError('Request failed', statusCode, {
  160. headers,
  161. data: {
  162. count: this.retryCount
  163. }
  164. })
  165. )
  166. return false
  167. }
  168. }
  169. // Checkpoint for resume from where we left it
  170. if (this.resume != null) {
  171. this.resume = null
  172. // Only Partial Content 206 supposed to provide Content-Range,
  173. // any other status code that partially consumed the payload
  174. // should not be retry because it would result in downstream
  175. // wrongly concatanete multiple responses.
  176. if (statusCode !== 206 && (this.start > 0 || statusCode !== 200)) {
  177. this.abort(
  178. new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
  179. headers,
  180. data: { count: this.retryCount }
  181. })
  182. )
  183. return false
  184. }
  185. const contentRange = parseRangeHeader(headers['content-range'])
  186. // If no content range
  187. if (!contentRange) {
  188. this.abort(
  189. new RequestRetryError('Content-Range mismatch', statusCode, {
  190. headers,
  191. data: { count: this.retryCount }
  192. })
  193. )
  194. return false
  195. }
  196. // Let's start with a weak etag check
  197. if (this.etag != null && this.etag !== headers.etag) {
  198. this.abort(
  199. new RequestRetryError('ETag mismatch', statusCode, {
  200. headers,
  201. data: { count: this.retryCount }
  202. })
  203. )
  204. return false
  205. }
  206. const { start, size, end = size - 1 } = contentRange
  207. assert(this.start === start, 'content-range mismatch')
  208. assert(this.end == null || this.end === end, 'content-range mismatch')
  209. this.resume = resume
  210. return true
  211. }
  212. if (this.end == null) {
  213. if (statusCode === 206) {
  214. // First time we receive 206
  215. const range = parseRangeHeader(headers['content-range'])
  216. if (range == null) {
  217. return this.handler.onHeaders(
  218. statusCode,
  219. rawHeaders,
  220. resume,
  221. statusMessage
  222. )
  223. }
  224. const { start, size, end = size - 1 } = 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 = resume
  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 (this.etag != null && this.etag.startsWith('W/')) {
  249. this.etag = null
  250. }
  251. return this.handler.onHeaders(
  252. statusCode,
  253. rawHeaders,
  254. resume,
  255. statusMessage
  256. )
  257. }
  258. const err = new RequestRetryError('Request failed', statusCode, {
  259. headers,
  260. data: { count: this.retryCount }
  261. })
  262. this.abort(err)
  263. return false
  264. }
  265. onData (chunk) {
  266. this.start += chunk.length
  267. return this.handler.onData(chunk)
  268. }
  269. onComplete (rawTrailers) {
  270. this.retryCount = 0
  271. return this.handler.onComplete(rawTrailers)
  272. }
  273. onError (err) {
  274. if (this.aborted || isDisturbed(this.opts.body)) {
  275. return this.handler.onError(err)
  276. }
  277. // We reconcile in case of a mix between network errors
  278. // and server error response
  279. if (this.retryCount - this.retryCountCheckpoint > 0) {
  280. // We count the difference between the last checkpoint and the current retry count
  281. this.retryCount =
  282. this.retryCountCheckpoint +
  283. (this.retryCount - this.retryCountCheckpoint)
  284. } else {
  285. this.retryCount += 1
  286. }
  287. this.retryOpts.retry(
  288. err,
  289. {
  290. state: { counter: this.retryCount },
  291. opts: { retryOptions: this.retryOpts, ...this.opts }
  292. },
  293. onRetry.bind(this)
  294. )
  295. function onRetry (err) {
  296. if (err != null || this.aborted || isDisturbed(this.opts.body)) {
  297. return this.handler.onError(err)
  298. }
  299. if (this.start !== 0) {
  300. const headers = { range: `bytes=${this.start}-${this.end ?? ''}` }
  301. // Weak etag check - weak etags will make comparison algorithms never match
  302. if (this.etag != null) {
  303. headers['if-match'] = this.etag
  304. }
  305. this.opts = {
  306. ...this.opts,
  307. headers: {
  308. ...this.opts.headers,
  309. ...headers
  310. }
  311. }
  312. }
  313. try {
  314. this.retryCountCheckpoint = this.retryCount
  315. this.dispatch(this.opts, this)
  316. } catch (err) {
  317. this.handler.onError(err)
  318. }
  319. }
  320. }
  321. }
  322. module.exports = RetryHandler