Skip to content

Commit 58e326b

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

File tree

6 files changed

+1914
-56
lines changed

6 files changed

+1914
-56
lines changed

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: 110 additions & 56 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
@@ -175,6 +215,7 @@ class RetryHandler {
175215
const contentRange = parseRangeHeader(headers['content-range'])
176216
// If no content range
177217
if (!contentRange) {
218+
// We always throw here as we want to indicate that we entred unexpected path
178219
throw new RequestRetryError('Content-Range mismatch', statusCode, {
179220
headers,
180221
data: { count: this.retryCount }
@@ -183,6 +224,7 @@ class RetryHandler {
183224

184225
// Let's start with a weak etag check
185226
if (this.etag != null && this.etag !== headers.etag) {
227+
// We always throw here as we want to indicate that we entred unexpected path
186228
throw new RequestRetryError('ETag mismatch', statusCode, {
187229
headers,
188230
data: { count: this.retryCount }
@@ -266,14 +308,52 @@ class RetryHandler {
266308
}
267309

268310
onResponseData (controller, chunk) {
311+
if (this.error) {
312+
return
313+
}
314+
269315
this.start += chunk.length
270316

271317
this.handler.onResponseData?.(controller, chunk)
272318
}
273319

274320
onResponseEnd (controller, trailers) {
275-
this.retryCount = 0
276-
return this.handler.onResponseEnd?.(controller, trailers)
321+
if (this.error && this.retryOpts.throwOnError) {
322+
throw this.error
323+
}
324+
325+
if (!this.error) {
326+
this.retryCount = 0
327+
return this.handler.onResponseEnd?.(controller, trailers)
328+
}
329+
330+
this.retry(controller)
331+
}
332+
333+
retry (controller) {
334+
if (this.start !== 0) {
335+
const headers = { range: `bytes=${this.start}-${this.end ?? ''}` }
336+
337+
// Weak etag check - weak etags will make comparison algorithms never match
338+
if (this.etag != null) {
339+
headers['if-match'] = this.etag
340+
}
341+
342+
this.opts = {
343+
...this.opts,
344+
headers: {
345+
...this.opts.headers,
346+
...headers
347+
}
348+
}
349+
}
350+
351+
try {
352+
this.retryCountCheckpoint = this.retryCount
353+
this.dispatch(this.opts, this)
354+
} catch (err) {
355+
this.handler.onResponseError?.(controller, err)
356+
}
277357
}
278358

279359
onResponseError (controller, err) {
@@ -282,6 +362,15 @@ class RetryHandler {
282362
return
283363
}
284364

365+
function shouldRetry (returnedErr) {
366+
if (!returnedErr) {
367+
this.retry(controller)
368+
return
369+
}
370+
371+
this.handler?.onResponseError?.(controller, returnedErr)
372+
}
373+
285374
// We reconcile in case of a mix between network errors
286375
// and server error response
287376
if (this.retryCount - this.retryCountCheckpoint > 0) {
@@ -299,43 +388,8 @@ class RetryHandler {
299388
state: { counter: this.retryCount },
300389
opts: { retryOptions: this.retryOpts, ...this.opts }
301390
},
302-
onRetry.bind(this)
391+
shouldRetry.bind(this)
303392
)
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-
}
339393
}
340394
}
341395

0 commit comments

Comments
 (0)