Skip to content

Commit 0e6f582

Browse files
authored
add retry for http retryable requests (#1390)
1 parent 9d66e6a commit 0e6f582

File tree

4 files changed

+85
-27
lines changed

4 files changed

+85
-27
lines changed

src/internal/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ import {
6666
} from './helper.ts'
6767
import { joinHostPort } from './join-host-port.ts'
6868
import { PostPolicy } from './post-policy.ts'
69-
import { request } from './request.ts'
69+
import { requestWithRetry } from './request.ts'
7070
import { drainResponse, readAsBuffer, readAsString } from './response.ts'
7171
import type { Region } from './s3-endpoints.ts'
7272
import { getS3Endpoint } from './s3-endpoints.ts'
@@ -723,7 +723,7 @@ export class TypedClient {
723723
reqOptions.headers.authorization = signV4(reqOptions, this.accessKey, this.secretKey, region, date, sha256sum)
724724
}
725725

726-
const response = await request(this.transport, reqOptions, body)
726+
const response = await requestWithRetry(this.transport, reqOptions, body)
727727
if (!response.statusCode) {
728728
throw new Error("BUG: response doesn't have a statusCode")
729729
}

src/internal/request.ts

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,89 @@ import type * as http from 'node:http'
22
import type * as https from 'node:https'
33
import type * as stream from 'node:stream'
44
import { pipeline } from 'node:stream'
5+
import { promisify } from 'node:util'
56

67
import type { Transport } from './type.ts'
78

9+
const pipelineAsync = promisify(pipeline)
10+
811
export async function request(
912
transport: Transport,
1013
opt: https.RequestOptions,
1114
body: Buffer | string | stream.Readable | null = null,
1215
): Promise<http.IncomingMessage> {
1316
return new Promise<http.IncomingMessage>((resolve, reject) => {
14-
const requestObj = transport.request(opt, (resp) => {
15-
resolve(resp)
17+
const requestObj = transport.request(opt, (response) => {
18+
resolve(response)
1619
})
1720

18-
if (!body || Buffer.isBuffer(body) || typeof body === 'string') {
19-
requestObj
20-
.on('error', (e: unknown) => {
21-
reject(e)
22-
})
23-
.end(body)
21+
requestObj.on('error', reject)
2422

25-
return
23+
if (!body || Buffer.isBuffer(body) || typeof body === 'string') {
24+
requestObj.end(body)
25+
} else {
26+
pipelineAsync(body, requestObj).catch(reject)
2627
}
28+
})
29+
}
30+
31+
const MAX_RETRIES = 10
32+
const EXP_BACK_OFF_BASE_DELAY = 1000 // Base delay for exponential backoff
33+
const ADDITIONAL_DELAY_FACTOR = 1.0 // to avoid synchronized retries
34+
35+
// Retryable error codes for HTTP ( ref: minio-go)
36+
export const retryHttpCodes: Record<string, boolean> = {
37+
408: true,
38+
429: true,
39+
499: true,
40+
500: true,
41+
502: true,
42+
503: true,
43+
504: true,
44+
520: true,
45+
}
46+
47+
const isHttpRetryable = (httpResCode: number) => {
48+
return retryHttpCodes[httpResCode] !== undefined
49+
}
50+
51+
const sleep = (ms: number) => {
52+
return new Promise((resolve) => setTimeout(resolve, ms))
53+
}
2754

28-
// pump readable stream
29-
pipeline(body, requestObj, (err) => {
30-
if (err) {
31-
reject(err)
55+
const getExpBackOffDelay = (retryCount: number) => {
56+
const backOffBy = EXP_BACK_OFF_BASE_DELAY * 2 ** retryCount
57+
const additionalDelay = Math.random() * backOffBy * ADDITIONAL_DELAY_FACTOR
58+
return backOffBy + additionalDelay
59+
}
60+
61+
export async function requestWithRetry(
62+
transport: Transport,
63+
opt: https.RequestOptions,
64+
body: Buffer | string | stream.Readable | null = null,
65+
maxRetries: number = MAX_RETRIES,
66+
): Promise<http.IncomingMessage> {
67+
let attempt = 0
68+
while (attempt <= maxRetries) {
69+
try {
70+
const response = await request(transport, opt, body)
71+
// Check if the HTTP status code is retryable
72+
if (isHttpRetryable(response.statusCode as number)) {
73+
throw new Error(`Retryable HTTP status: ${response.statusCode}`) // trigger retry attempt with calculated delay
3274
}
33-
})
34-
})
75+
return response // Success, return the raw response
76+
} catch (err) {
77+
attempt++
78+
79+
if (attempt > maxRetries) {
80+
throw new Error(`Request failed after ${maxRetries} retries: ${err}`)
81+
}
82+
const delay = getExpBackOffDelay(attempt)
83+
// eslint-disable-next-line no-console
84+
// console.warn( `${new Date().toLocaleString()} Retrying request (attempt ${attempt}/${maxRetries}) after ${delay}ms due to: ${err}`,)
85+
await sleep(delay)
86+
}
87+
}
88+
89+
throw new Error(`${MAX_RETRIES} Retries exhausted, request failed.`)
3590
}

src/internal/xml-parser.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ export function parseError(xml: string, headerInfo: Record<string, unknown>) {
5656
}
5757

5858
// Generates an Error object depending on http statusCode and XML body
59-
export async function parseResponseError(response: http.IncomingMessage) {
59+
export async function parseResponseError(response: http.IncomingMessage): Promise<Record<string, string>> {
6060
const statusCode = response.statusCode
61-
let code: string, message: string
61+
let code = '',
62+
message = ''
6263
if (statusCode === 301) {
6364
code = 'MovedPermanently'
6465
message = 'Moved Permanently'
@@ -77,9 +78,17 @@ export async function parseResponseError(response: http.IncomingMessage) {
7778
} else if (statusCode === 501) {
7879
code = 'MethodNotAllowed'
7980
message = 'Method Not Allowed'
81+
} else if (statusCode === 503) {
82+
code = 'SlowDown'
83+
message = 'Please reduce your request rate.'
8084
} else {
81-
code = 'UnknownError'
82-
message = `${statusCode}`
85+
const hErrCode = response.headers['x-minio-error-code'] as string
86+
const hErrDesc = response.headers['x-minio-error-desc'] as string
87+
88+
if (hErrCode && hErrDesc) {
89+
code = hErrCode
90+
message = hErrDesc
91+
}
8392
}
8493
const headerInfo: Record<string, string | undefined | null> = {}
8594
// A value created by S3 compatible server that uniquely identifies the request.

tests/unit/test.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -751,12 +751,6 @@ describe('Client', function () {
751751
() => done(),
752752
)
753753
})
754-
it('should fail on incompatible argument type (null) for statOpts object', (done) => {
755-
client.statObject('hello', 'testStatOpts', null).then(
756-
() => done(new Error('expecting error')),
757-
() => done(),
758-
)
759-
})
760754
it('should fail on incompatible argument type (sting) for statOpts object', (done) => {
761755
client.statObject('hello', 'testStatOpts', ' ').then(
762756
() => done(new Error('expecting error')),

0 commit comments

Comments
 (0)