Skip to content

Commit 230ec24

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

6 files changed

Lines changed: 1911 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: 107 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,36 @@ class RetryHandler {
6871
this.etag = null
6972
}
7073

74+
onResponseStartWithRetry (controller, statusCode, headers, statusMessage, err) {
75+
if (isDisturbed(this.opts.body)) {
76+
this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
77+
return
78+
}
79+
80+
function shouldRetry (passedErr) {
81+
if (passedErr) {
82+
this.headersSent = true
83+
84+
this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
85+
controller.resume()
86+
return
87+
}
88+
89+
this.error = err
90+
controller.resume()
91+
}
92+
93+
controller.pause()
94+
this.retryOpts.retry(
95+
err,
96+
{
97+
state: { counter: this.retryCount },
98+
opts: { retryOptions: this.retryOpts, ...this.opts }
99+
},
100+
shouldRetry.bind(this)
101+
)
102+
}
103+
71104
onRequestStart (controller, context) {
72105
if (!this.headersSent) {
73106
this.handler.onRequestStart?.(controller, context)
@@ -137,26 +170,19 @@ class RetryHandler {
137170
}
138171

139172
onResponseStart (controller, statusCode, headers, statusMessage) {
173+
this.error = null
140174
this.retryCount += 1
141175

142176
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-
}
177+
const err = new RequestRetryError('Request failed', statusCode, {
178+
headers,
179+
data: {
180+
count: this.retryCount
181+
}
182+
})
183+
184+
this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
185+
return
160186
}
161187

162188
// Checkpoint for resume from where we left it
@@ -166,15 +192,20 @@ class RetryHandler {
166192
// should not be retried because it would result in downstream
167193
// wrongly concatenate multiple responses.
168194
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, {
195+
const err = new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, {
170196
headers,
171197
data: { count: this.retryCount }
172198
})
199+
200+
this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
201+
202+
return
173203
}
174204

175205
const contentRange = parseRangeHeader(headers['content-range'])
176206
// If no content range
177207
if (!contentRange) {
208+
// We always throw here as we want to indicate that we entred unexpected path
178209
throw new RequestRetryError('Content-Range mismatch', statusCode, {
179210
headers,
180211
data: { count: this.retryCount }
@@ -183,6 +214,7 @@ class RetryHandler {
183214

184215
// Let's start with a weak etag check
185216
if (this.etag != null && this.etag !== headers.etag) {
217+
// We always throw here as we want to indicate that we entred unexpected path
186218
throw new RequestRetryError('ETag mismatch', statusCode, {
187219
headers,
188220
data: { count: this.retryCount }
@@ -258,22 +290,65 @@ class RetryHandler {
258290
statusMessage
259291
)
260292
} else {
261-
throw new RequestRetryError('Request failed', statusCode, {
293+
const err = new RequestRetryError('Request failed', statusCode, {
262294
headers,
263295
data: { count: this.retryCount }
264296
})
297+
298+
this.onResponseStartWithRetry(controller, statusCode, headers, statusMessage, err)
299+
300+
// eslint-disable-next-line no-useless-return
301+
return
265302
}
266303
}
267304

268305
onResponseData (controller, chunk) {
306+
if (this.error) {
307+
return
308+
}
309+
269310
this.start += chunk.length
270311

271312
this.handler.onResponseData?.(controller, chunk)
272313
}
273314

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

279354
onResponseError (controller, err) {
@@ -282,6 +357,15 @@ class RetryHandler {
282357
return
283358
}
284359

360+
function shouldRetry (returnedErr) {
361+
if (!returnedErr) {
362+
this.retry(controller)
363+
return
364+
}
365+
366+
this.handler?.onResponseError?.(controller, returnedErr)
367+
}
368+
285369
// We reconcile in case of a mix between network errors
286370
// and server error response
287371
if (this.retryCount - this.retryCountCheckpoint > 0) {
@@ -299,43 +383,8 @@ class RetryHandler {
299383
state: { counter: this.retryCount },
300384
opts: { retryOptions: this.retryOpts, ...this.opts }
301385
},
302-
onRetry.bind(this)
386+
shouldRetry.bind(this)
303387
)
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-
}
339388
}
340389
}
341390

0 commit comments

Comments
 (0)