Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions core/packages/gax/src/normalCalls/retries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@
return (argument: RequestType, callback: APICallback) => {
let canceller: GRPCCallResult | null;
let timeoutId: ReturnType<typeof setTimeout> | null;
// Set by `cancel()` below. The retry decision is made purely on the error
// code, which cannot distinguish a transport failure from the abort this
// call issued itself, so the caller's intent has to be recorded.
let cancelled = false;
let now = new Date();
let deadline: number;
if (retry.backoffSettings.totalTimeoutMillis) {
Expand Down Expand Up @@ -152,6 +156,32 @@
return;
}
canceller = null;
// The caller cancelled, so this error is the consequence of the abort
// issued just above, whatever the transport chose to report it as.
//
// Without this, the decision below is made on the error code alone. A
// transport that reports an aborted request as a retryable code — the
// REST fallback reports transport failures as UNAVAILABLE — schedules
// another attempt, which discards the cancellation. Worse, `cancel()`
// has already run: `canceller` is null and the timer it would have
// cleared has not been created yet, so nothing remains that can stop
// the new attempt. It runs unbounded and the caller's callback is
// never invoked.
if (cancelled) {
// A transport that already reported the abort as CANCELLED has said
// everything worth saying; re-wrapping would only bury it.
if (err.code === Status.CANCELLED) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since err can be null or undefined (as indicated by the use of non-null assertions like err!.code! on line 187), accessing err.code directly here will cause a TypeScript compilation error under strict null checks, or a runtime TypeError if err is indeed nullish.

Using optional chaining (err?.code) is safer and adheres to defensive programming practices.

Suggested change
if (err.code === Status.CANCELLED) {
if (err?.code === Status.CANCELLED) {

callback(err);
return;
}
const error = new GoogleError(
'cancelled' + errorDetailsSuffix(errorsEncountered),
{cause: err},
);
error.code = Status.CANCELLED;
callback(error);
return;
}
if (
retry.retryCodes.length > 0 &&
retry.retryCodes.indexOf(err!.code!) < 0
Expand All @@ -175,8 +205,8 @@
}
});
if (canceller instanceof Promise) {
canceller.catch(err => {

Check warning on line 208 in core/packages/gax/src/normalCalls/retries.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid using promises inside of callbacks
callback(new GoogleError(err));

Check warning on line 209 in core/packages/gax/src/normalCalls/retries.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
});
}
}
Expand All @@ -194,6 +224,10 @@

return {
cancel() {
// Recorded before aborting: `canceller.cancel()` can drive the
// transport's callback synchronously, and the guard there has to see
// this already set.
cancelled = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
Expand Down
95 changes: 95 additions & 0 deletions core/packages/gax/test/unit/apiCallable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,9 @@
assert.ok(Array.isArray(response));
assert.strictEqual(response[0], 42);
assert.ok(deadlineArg);
return done();

Check warning on line 378 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
})
.catch(done);

Check warning on line 380 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
});

it('emits error on rejected promise', async () => {
Expand Down Expand Up @@ -405,12 +405,12 @@
const promise = (apiCall as any)(null);
promise
.then(() => {
return done(new Error('should not reach'));

Check warning on line 408 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
})
.catch((err: {code: number}) => {
assert(err instanceof GoogleError);
assert.strictEqual(err.code, status.CANCELLED);
done();

Check warning on line 413 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
});
promise.cancel();
});
Expand Down Expand Up @@ -445,18 +445,113 @@
const promise = (apiCall as any)(null);
promise
.then(() => {
return done(new Error('should not reach'));

Check warning on line 448 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
})
.catch(() => {
assert(callCount < 4);
done();

Check warning on line 452 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
})
.catch(done);

Check warning on line 454 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
setTimeout(() => {
promise.cancel();
}, 15);
});

it('does not retry a cancelled call whose error is retryable', done => {
// The REST fallback reports any transport-level failure as UNAVAILABLE,
// including the abort that `cancel()` itself triggers, so a cancelled call
// arrives here carrying a retryable code. Retrying it would discard the
// cancellation, and because `cancel()` has already run nothing remains that
// could stop the new attempt: it would run unbounded and this promise would
// never settle. Before the fix this test times out rather than failing.
const retryOptions = gax.createRetryOptions(
[status.UNAVAILABLE],
gax.createBackoffSettings(1, 1, 1, 1000, 1, 1000, 5000),
);

let callCount = 0;
function func(

Check failure on line 473 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎······argument:·{},⏎······metadata:·{},⏎······options:·{},⏎······callback:·Function,⏎····` with `argument:·{},·metadata:·{},·options:·{},·callback:·Function`
argument: {},
metadata: {},
options: {},
callback: Function,
) {
callCount++;
// Completes only when cancelled, like a request to an endpoint that
// accepted the connection and then went quiet.
return function cancelFunc() {
const err = new GoogleError('The operation was aborted.');
err.code = status.UNAVAILABLE;
callback(err);
};
}

const apiCall = createApiCall(func, {
settings: {retry: retryOptions},
returnCancelFunc: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promise = (apiCall as any)(null);
promise
.then(() => done(new Error('should not reach')))

Check warning on line 496 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
.catch((err: GoogleError) => {
try {
assert.strictEqual(err.code, status.CANCELLED);
assert.strictEqual(
callCount,
1,
`expected no retry after cancel, saw ${callCount} invocations`,
);
done();
} catch (e) {
done(e);
}
});
setTimeout(() => promise.cancel(), 10);
});

it('keeps a CANCELLED reported by the transport intact', done => {
// A transport that already resolved the abort to CANCELLED has said
// everything worth saying, so it should not be re-wrapped.
const retryOptions = gax.createRetryOptions(
[status.UNAVAILABLE],
gax.createBackoffSettings(1, 1, 1, 1000, 1, 1000, 5000),
);

const transportError = new GoogleError('cancelled by the transport');
transportError.code = status.CANCELLED;

function func(

Check failure on line 524 in core/packages/gax/test/unit/apiCallable.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `⏎······argument:·{},⏎······metadata:·{},⏎······options:·{},⏎······callback:·Function,⏎····` with `argument:·{},·metadata:·{},·options:·{},·callback:·Function`
argument: {},
metadata: {},
options: {},
callback: Function,
) {
return function cancelFunc() {
callback(transportError);
};
}

const apiCall = createApiCall(func, {
settings: {retry: retryOptions},
returnCancelFunc: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promise = (apiCall as any)(null);
promise
.then(() => done(new Error('should not reach')))
.catch((err: GoogleError) => {
try {
assert.strictEqual(err, transportError);
assert.strictEqual(err.message, 'cancelled by the transport');
done();
} catch (e) {
done(e);
}
});
setTimeout(() => promise.cancel(), 10);
});

it('does not return promise when callback is supplied', done => {
function func(argument: {}, metadata: {}, options: {}, callback: Function) {
callback(null, 42);
Expand Down
Loading