Skip to content

Commit e742b11

Browse files
authored
Merge pull request #3102 from phyous/fix-retry-cancel-during-backoff
fix(grpc-js): stop retry attempts after the call is cancelled
2 parents f03edc9 + a738dfb commit e742b11

2 files changed

Lines changed: 157 additions & 1 deletion

File tree

‎packages/grpc-js/src/retrying-call.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export class RetryingCall implements Call, DeadlineInfoProvider {
207207
*/
208208
private attempts = 0;
209209
private hedgingTimer: NodeJS.Timeout | null = null;
210+
private retryTimer: NodeJS.Timeout | null = null;
210211
private committedCallIndex: number | null = null;
211212
private initialRetryBackoffSec = 0;
212213
private nextRetryBackoffSec = 0;
@@ -321,6 +322,18 @@ export class RetryingCall implements Call, DeadlineInfoProvider {
321322
'cancelWithStatus code: ' + status + ' details: "' + details + '"'
322323
);
323324
}
325+
/* No new attempt may start after cancellation. Otherwise a pending retry
326+
* or hedging timer would start one, opening a stream that nothing will
327+
* ever end. */
328+
this.state = 'NO_RETRY';
329+
if (this.retryTimer) {
330+
clearTimeout(this.retryTimer);
331+
this.retryTimer = null;
332+
}
333+
if (this.hedgingTimer) {
334+
clearTimeout(this.hedgingTimer);
335+
this.hedgingTimer = null;
336+
}
324337
this.reportStatus({ code: status, details, metadata: new Metadata() });
325338
for (const { call } of this.underlyingCalls) {
326339
call.cancelWithStatus(status, details);
@@ -487,7 +500,8 @@ export class RetryingCall implements Call, DeadlineInfoProvider {
487500
retryDelayMs = pushback;
488501
this.nextRetryBackoffSec = this.initialRetryBackoffSec;
489502
}
490-
setTimeout(() => {
503+
this.retryTimer = setTimeout(() => {
504+
this.retryTimer = null;
491505
if (this.state !== 'RETRY') {
492506
callback(false);
493507
return;

‎packages/grpc-js/test/test-retry.ts‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,3 +622,145 @@ describe('Retries', () => {
622622
});
623623
});
624624
});
625+
626+
describe('Retries after cancellation', () => {
627+
/* Counts the attempts started for each call-id. Client streaming handlers
628+
* run as soon as an attempt's headers arrive, so every attempt is counted. */
629+
const attemptCounts = new Map<string, number>();
630+
const cancellationServiceImpl = {
631+
echo: (
632+
call: grpc.ServerUnaryCall<any, any>,
633+
callback: grpc.sendUnaryData<any>
634+
) => {
635+
const id = call.metadata.get('call-id')[0] as string | undefined;
636+
if (id) {
637+
attemptCounts.set(id, (attemptCounts.get(id) ?? 0) + 1);
638+
callback({ code: grpc.status.UNAVAILABLE, details: 'Unavailable' });
639+
} else {
640+
callback(null, call.request);
641+
}
642+
},
643+
echoClientStream: (
644+
call: grpc.ServerReadableStream<any, any>,
645+
callback: grpc.sendUnaryData<any>
646+
) => {
647+
const id = call.metadata.get('call-id')[0] as string;
648+
attemptCounts.set(id, (attemptCounts.get(id) ?? 0) + 1);
649+
callback({ code: grpc.status.UNAVAILABLE, details: 'Unavailable' });
650+
},
651+
};
652+
const serviceConfig = {
653+
loadBalancingConfig: [],
654+
methodConfig: [
655+
{
656+
name: [{ service: 'EchoService' }],
657+
retryPolicy: {
658+
maxAttempts: 3,
659+
initialBackoff: '0.3s',
660+
maxBackoff: '0.3s',
661+
backoffMultiplier: 1,
662+
retryableStatusCodes: ['UNAVAILABLE'],
663+
},
664+
},
665+
],
666+
};
667+
let server: grpc.Server;
668+
let client: InstanceType<grpc.ServiceClientConstructor>;
669+
before(done => {
670+
server = new grpc.Server({ 'grpc.max_concurrent_streams': 3 });
671+
server.addService(EchoService.service, cancellationServiceImpl);
672+
server.bindAsync(
673+
'localhost:0',
674+
grpc.ServerCredentials.createInsecure(),
675+
(error, port) => {
676+
if (error) {
677+
done(error);
678+
return;
679+
}
680+
client = new EchoService(
681+
`localhost:${port}`,
682+
grpc.credentials.createInsecure(),
683+
{ 'grpc.service_config': JSON.stringify(serviceConfig) }
684+
);
685+
done();
686+
}
687+
);
688+
});
689+
690+
after(() => {
691+
client.close();
692+
server.forceShutdown();
693+
});
694+
695+
function cancelDuringBackoff(
696+
id: string,
697+
streaming: boolean,
698+
done: () => void
699+
) {
700+
const metadata = new grpc.Metadata();
701+
metadata.set('call-id', id);
702+
let call: grpc.ClientWritableStream<any> | grpc.ClientUnaryCall;
703+
if (streaming) {
704+
const stream = client.echoClientStream(metadata, () => {});
705+
stream.write({ value: 'test value', value2: 3 });
706+
call = stream;
707+
} else {
708+
call = client.echo(
709+
{ value: 'test value', value2: 3 },
710+
metadata,
711+
() => {}
712+
);
713+
}
714+
const waitForFirstAttempt = () => {
715+
if (!attemptCounts.get(id)) {
716+
setTimeout(waitForFirstAttempt, 5);
717+
return;
718+
}
719+
// The first attempt failed; the retry is waiting out its backoff.
720+
setTimeout(() => {
721+
call.cancel();
722+
done();
723+
}, 50);
724+
};
725+
waitForFirstAttempt();
726+
}
727+
728+
it('Should not start new attempts after the call is cancelled during backoff', done => {
729+
cancelDuringBackoff('cancelled', true, () => {
730+
setTimeout(() => {
731+
assert.strictEqual(attemptCounts.get('cancelled'), 1);
732+
done();
733+
}, 1000);
734+
});
735+
});
736+
737+
it('Should not leak streams when calls are cancelled during backoff', done => {
738+
let cancelled = 0;
739+
for (let i = 0; i < 3; i++) {
740+
/* A unary retry attempt started after cancellation has no message to
741+
* send, so the server never responds and its stream stays open. */
742+
cancelDuringBackoff(`leak-${i}`, false, () => {
743+
cancelled += 1;
744+
if (cancelled < 3) {
745+
return;
746+
}
747+
// The server allows 3 concurrent streams. Leaked retry attempts would
748+
// hold all of them, so this call could never start.
749+
setTimeout(() => {
750+
client.echo(
751+
{ value: 'test value', value2: 3 },
752+
{ deadline: Date.now() + 2000 },
753+
(error: grpc.ServiceError, response: any) => {
754+
assert.ifError(error);
755+
assert.deepStrictEqual(response, {
756+
value: 'test value',
757+
value2: 3,
758+
});
759+
done();
760+
}
761+
);
762+
}, 500);
763+
});
764+
}
765+
});
766+
});

0 commit comments

Comments
 (0)