@@ -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