Skip to content
This repository was archived by the owner on Mar 3, 2026. It is now read-only.

Commit ba35321

Browse files
authored
fix: Retry EPIPE Connection Errors + Attempt Retries in Resumable Upload Connection Errors (#2040)
* feat: Add `epipe` as retryable error * fix: capture and retry potential connection errors * test: Add tests and remove logs * test: set `retryOptions` by copy rather than reference * fix: grammar
1 parent e1c63ce commit ba35321

4 files changed

Lines changed: 75 additions & 10 deletions

File tree

src/resumable-upload.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -742,9 +742,18 @@ export class Upload extends Writable {
742742
responseReceived = true;
743743
this.responseHandler(resp);
744744
}
745-
} catch (err) {
746-
const e = err as Error;
747-
this.destroy(e);
745+
} catch (e) {
746+
const err = e as ApiError;
747+
748+
if (this.retryOptions.retryableErrorFn!(err)) {
749+
this.attemptDelayedRetry({
750+
status: NaN,
751+
data: err,
752+
});
753+
return;
754+
}
755+
756+
this.destroy(err);
748757
}
749758
}
750759

@@ -833,7 +842,16 @@ export class Upload extends Writable {
833842
}
834843
this.offset = 0;
835844
} catch (e) {
836-
const err = e as GaxiosError;
845+
const err = e as ApiError;
846+
847+
if (this.retryOptions.retryableErrorFn!(err)) {
848+
this.attemptDelayedRetry({
849+
status: NaN,
850+
data: err,
851+
});
852+
return;
853+
}
854+
837855
this.destroy(err);
838856
}
839857
}
@@ -922,7 +940,7 @@ export class Upload extends Writable {
922940
/**
923941
* @param resp GaxiosResponse object from previous attempt
924942
*/
925-
private attemptDelayedRetry(resp: GaxiosResponse) {
943+
private attemptDelayedRetry(resp: Pick<GaxiosResponse, 'data' | 'status'>) {
926944
if (this.numRetries < this.retryOptions.maxRetries!) {
927945
if (
928946
resp.status === NOT_FOUND_STATUS_CODE &&

src/storage.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,11 +261,12 @@ const IDEMPOTENCY_STRATEGY_DEFAULT = IdempotencyStrategy.RetryConditional;
261261
* @return {boolean} True if the API request should be retried, false otherwise.
262262
*/
263263
export const RETRYABLE_ERR_FN_DEFAULT = function (err?: ApiError) {
264-
const isConnectionProblem = (reason: string | undefined) => {
264+
const isConnectionProblem = (reason: string) => {
265265
return (
266-
(reason && reason.includes('eai_again')) || //DNS lookup error
266+
reason.includes('eai_again') || // DNS lookup error
267267
reason === 'econnreset' ||
268-
reason === 'unexpected connection closure'
268+
reason === 'unexpected connection closure' ||
269+
reason === 'epipe'
269270
);
270271
};
271272

@@ -284,7 +285,7 @@ export const RETRYABLE_ERR_FN_DEFAULT = function (err?: ApiError) {
284285
if (err.errors) {
285286
for (const e of err.errors) {
286287
const reason = e?.reason?.toString().toLowerCase();
287-
if (isConnectionProblem(reason)) {
288+
if (reason && isConnectionProblem(reason)) {
288289
return true;
289290
}
290291
}

test/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,20 @@ describe('Storage', () => {
311311
assert.strictEqual(calledWith.retryOptions.retryableErrorFn(error), true);
312312
});
313313

314+
it('should retry a broken pipe error', () => {
315+
const storage = new Storage({
316+
projectId: PROJECT_ID,
317+
});
318+
const calledWith = storage.calledWith_[0];
319+
const error = new ApiError('Broken pipe');
320+
error.errors = [
321+
{
322+
reason: 'EPIPE',
323+
},
324+
];
325+
assert.strictEqual(calledWith.retryOptions.retryableErrorFn(error), true);
326+
});
327+
314328
it('should not retry a 999 error', () => {
315329
const storage = new Storage({
316330
projectId: PROJECT_ID,

test/resumable-upload.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ describe('resumable-upload', () => {
112112
userProject: USER_PROJECT,
113113
authConfig: {keyFile},
114114
apiEndpoint: API_ENDPOINT,
115-
retryOptions: RETRY_OPTIONS,
115+
retryOptions: {...RETRY_OPTIONS},
116116
});
117117
});
118118

@@ -1028,6 +1028,22 @@ describe('resumable-upload', () => {
10281028
up.startUploading();
10291029
});
10301030

1031+
it('should retry retryable errors if the request failed', done => {
1032+
const error = new Error('Error.');
1033+
1034+
// mock as retryable
1035+
up.retryOptions.retryableErrorFn = () => true;
1036+
1037+
up.on('error', done);
1038+
up.attemptDelayedRetry = () => done();
1039+
1040+
up.makeRequestStream = async () => {
1041+
throw error;
1042+
};
1043+
1044+
up.startUploading();
1045+
});
1046+
10311047
describe('request preparation', () => {
10321048
// a convenient handle for getting the request options
10331049
let reqOpts: GaxiosOptions;
@@ -1384,6 +1400,22 @@ describe('resumable-upload', () => {
13841400
await up.getAndSetOffset();
13851401
assert.strictEqual(up.offset, 0);
13861402
});
1403+
1404+
it('should retry retryable errors if the request failed', done => {
1405+
const error = new Error('Error.');
1406+
1407+
// mock as retryable
1408+
up.retryOptions.retryableErrorFn = () => true;
1409+
1410+
up.on('error', done);
1411+
up.attemptDelayedRetry = () => done();
1412+
1413+
up.makeRequest = async () => {
1414+
throw error;
1415+
};
1416+
1417+
up.getAndSetOffset();
1418+
});
13871419
});
13881420

13891421
describe('#makeRequest', () => {

0 commit comments

Comments
 (0)