Skip to content

Commit c48ed92

Browse files
committed
Fix: Provide body on retry error, preserve socket
backward compatibility
1 parent dcf82a7 commit c48ed92

6 files changed

Lines changed: 1925 additions & 58 deletions

File tree

docs/docs/api/RetryAgent.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Returns: `ProxyAgent`
1616

1717
### Parameter: `RetryHandlerOptions`
1818

19+
- **throwOnError** `boolean` (optional) - Disable to prevent throwing error on last retry attept, useful if you need the body on errors from server or if you have custom erro handler. Default: `true`
1920
- **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => void` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed.
2021
- **maxRetries** `number` (optional) - Maximum number of retries. Default: `5`
2122
- **maxTimeout** `number` (optional) - Maximum number of milliseconds to wait before retrying. Default: `30000` (30 seconds)

docs/docs/api/RetryHandler.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Extends: [`Dispatch.DispatchOptions`](/docs/docs/api/Dispatcher.md#parameter-dis
1919

2020
#### `RetryOptions`
2121

22+
- **throwOnError** `boolean` (optional) - Disable to prevent throwing error on last retry attept, useful if you need the body on errors from server or if you have custom erro handler.
2223
- **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => number | null` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed.
2324
- **maxRetries** `number` (optional) - Maximum number of retries. Default: `5`
2425
- **maxTimeout** `number` (optional) - Maximum number of milliseconds to wait before retrying. Default: `30000` (30 seconds)

lib/handler/retry-handler.js

Lines changed: 121 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,16 @@ class RetryHandler {
2929
methods,
3030
errorCodes,
3131
retryAfter,
32-
statusCodes
32+
statusCodes,
33+
throwOnError
3334
} = retryOptions ?? {}
3435

36+
this.error = null
3537
this.dispatch = dispatch
3638
this.handler = WrapHandler.wrap(handler)
3739
this.opts = { ...dispatchOpts, body: wrapRequestBody(opts.body) }
3840
this.retryOpts = {
41+
throwOnError: throwOnError ?? true,
3942
retry: retryFn ?? RetryHandler[kRetryHandlerDefaultRetry],
4043
retryAfter: retryAfter ?? true,
4144
maxTimeout: maxTimeout ?? 30 * 1000, // 30s,
@@ -68,6 +71,50 @@ class RetryHandler {
6871
this.etag = null
6972
}
7073

74+
onResponseStartWithRetry (controller, statusCode, headers, statusMessage, err) {
75+
if (this.retryOpts.throwOnError) {
76+
// Preserve old behavior for status codes that are not eligible for retry
77+
if (this.retryOpts.statusCodes.includes(statusCode) === false) {
78+
this.headersSent = true
79+
this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
80+
} else {
81+
this.error = err
82+
}
83+
84+
return
85+
}
86+
87+
if (isDisturbed(this.opts.body)) {
88+
this.headersSent = true
89+
this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
90+
return
91+
}
92+
93+
function shouldRetry (passedErr) {
94+
if (passedErr) {
95+
this.headersSent = true
96+
97+
this.headersSent = true
98+
this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
99+
controller.resume()
100+
return
101+
}
102+
103+
this.error = err
104+
controller.resume()
105+
}
106+
107+
controller.pause()
108+
this.retryOpts.retry(
109+
err,
110+
{
111+
state: { counter: this.retryCount },
112+
opts: { retryOptions: this.retryOpts, ...this.opts }
113+
},
114+
shouldRetry.bind(this)
115+
)
116+
}
117+
71118
onRequestStart (controller, context) {
72119
if (!this.headersSent) {
73120
this.handler.onRequestStart?.(controller, context)
@@ -137,26 +184,19 @@ class RetryHandler {
137184
}
138185

139186
onResponseStart (controller, statusCode, headers, statusMessage) {
187+
this.error = null
140188
this.retryCount += 1
141189

142190
if (statusCode >= 300) {
143-
if (this.retryOpts.statusCodes.includes(statusCode) === false) {
144-
this.headersSent = true
145-
this.handler.onResponseStart?.(
146-
controller,
147-
statusCode,
148-
headers,
149-
statusMessage
150-
)
151-
return
152-
} else {
153-
throw new RequestRetryError('Request failed', statusCode, {
154-
headers,
155-
data: {
156-
count: this.retryCount
157-
}
158-
})
159-
}
191+
const err = new RequestRetryError('Request failed', statusCode, {
192+
headers,
193+
data: {
194+
count: this.retryCount
195+
}
196+
})
197+
198+
this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
199+
return
160200
}
161201

162202
// Checkpoint for resume from where we left it
@@ -166,15 +206,20 @@ class RetryHandler {
166206
// should not be retried because it would result in downstream
167207
// wrongly concatenate multiple responses.
168208
if (statusCode !== 206 && (this.start > 0 || statusCode !== 200)) {
169-
throw new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
209+
const err = new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
170210
headers,
171211
data: { count: this.retryCount }
172212
})
213+
214+
this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
215+
216+
return
173217
}
174218

175219
const contentRange = parseRangeHeader(headers['content-range'])
176220
// If no content range
177221
if (!contentRange) {
222+
// We always throw here as we want to indicate that we entred unexpected path
178223
throw new RequestRetryError('Content-Range mismatch', statusCode, {
179224
headers,
180225
data: { count: this.retryCount }
@@ -183,6 +228,7 @@ class RetryHandler {
183228

184229
// Let's start with a weak etag check
185230
if (this.etag != null && this.etag !== headers.etag) {
231+
// We always throw here as we want to indicate that we entred unexpected path
186232
throw new RequestRetryError('ETag mismatch', statusCode, {
187233
headers,
188234
data: { count: this.retryCount }
@@ -258,22 +304,65 @@ class RetryHandler {
258304
statusMessage
259305
)
260306
} else {
261-
throw new RequestRetryError('Request failed', statusCode, {
307+
const err = new RequestRetryError('Request failed', statusCode, {
262308
headers,
263309
data: { count: this.retryCount }
264310
})
311+
312+
this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
313+
314+
// eslint-disable-next-line no-useless-return
315+
return
265316
}
266317
}
267318

268319
onResponseData (controller, chunk) {
320+
if (this.error) {
321+
return
322+
}
323+
269324
this.start += chunk.length
270325

271326
this.handler.onResponseData?.(controller, chunk)
272327
}
273328

274329
onResponseEnd (controller, trailers) {
275-
this.retryCount = 0
276-
return this.handler.onResponseEnd?.(controller, trailers)
330+
if (this.error && this.retryOpts.throwOnError) {
331+
throw this.error
332+
}
333+
334+
if (!this.error) {
335+
this.retryCount = 0
336+
return this.handler.onResponseEnd?.(controller, trailers)
337+
}
338+
339+
this.retry(controller)
340+
}
341+
342+
retry (controller) {
343+
if (this.start !== 0) {
344+
const headers = { range: `bytes=${this.start}-${this.end ?? ''}` }
345+
346+
// Weak etag check - weak etags will make comparison algorithms never match
347+
if (this.etag != null) {
348+
headers['if-match'] = this.etag
349+
}
350+
351+
this.opts = {
352+
...this.opts,
353+
headers: {
354+
...this.opts.headers,
355+
...headers
356+
}
357+
}
358+
}
359+
360+
try {
361+
this.retryCountCheckpoint = this.retryCount
362+
this.dispatch(this.opts, this)
363+
} catch (err) {
364+
this.handler.onResponseError?.(controller, err)
365+
}
277366
}
278367

279368
onResponseError (controller, err) {
@@ -282,6 +371,15 @@ class RetryHandler {
282371
return
283372
}
284373

374+
function shouldRetry (returnedErr) {
375+
if (!returnedErr) {
376+
this.retry(controller)
377+
return
378+
}
379+
380+
this.handler?.onResponseError?.(controller, returnedErr)
381+
}
382+
285383
// We reconcile in case of a mix between network errors
286384
// and server error response
287385
if (this.retryCount - this.retryCountCheckpoint > 0) {
@@ -299,43 +397,8 @@ class RetryHandler {
299397
state: { counter: this.retryCount },
300398
opts: { retryOptions: this.retryOpts, ...this.opts }
301399
},
302-
onRetry.bind(this)
400+
shouldRetry.bind(this)
303401
)
304-
305-
/**
306-
* @this {RetryHandler}
307-
* @param {Error} [err]
308-
* @returns
309-
*/
310-
function onRetry (err) {
311-
if (err != null || controller?.aborted || isDisturbed(this.opts.body)) {
312-
return this.handler.onResponseError?.(controller, err)
313-
}
314-
315-
if (this.start !== 0) {
316-
const headers = { range: `bytes=${this.start}-${this.end ?? ''}` }
317-
318-
// Weak etag check - weak etags will make comparison algorithms never match
319-
if (this.etag != null) {
320-
headers['if-match'] = this.etag
321-
}
322-
323-
this.opts = {
324-
...this.opts,
325-
headers: {
326-
...this.opts.headers,
327-
...headers
328-
}
329-
}
330-
}
331-
332-
try {
333-
this.retryCountCheckpoint = this.retryCount
334-
this.dispatch(this.opts, this)
335-
} catch (err) {
336-
this.handler.onResponseError?.(controller, err)
337-
}
338-
}
339402
}
340403
}
341404

0 commit comments

Comments
 (0)