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