Skip to content

fix(grpc-js): stop retry attempts after the call is cancelled - #3102

Open
phyous wants to merge 1 commit into
grpc:masterfrom
phyous:fix-retry-cancel-during-backoff
Open

phyous wants to merge 1 commit into
grpc:masterfrom
phyous:fix-retry-cancel-during-backoff

Conversation

@phyous

@phyous phyous commented Sep 27, 2026

Copy link
Copy Markdown

Problem

When a call with a retryPolicy is cancelled while a retry is waiting out its backoff, RetryingCall keeps retrying. cancelWithStatus() reports the status and cancels the child calls, but it leaves state as 'RETRY' and doesn't clear the backoff timer (the timer handle isn't stored). When the timer fires, it calls startNewAttempt() for a call the application has already cancelled. If that attempt fails with a retryable status, the call continues through its remaining attempts.

This has two visible effects:

  • The server runs handlers for cancelled calls. For a client-streaming call cancelled after its first attempt, the server handler ran 3 times instead of once (all maxAttempts).
  • Streams leak and can block the channel. reportStatus() has already cleared the write buffer, so a unary attempt sends headers but no message. The server never responds, and the stream stays open until the connection closes. Each one occupies a slot under the server's MAX_CONCURRENT_STREAMS. With a limit of 100 (typical for proxies and load balancers), cancelling 100 calls during backoff makes a later call to a healthy server fail with DEADLINE_EXCEEDED. The same flow succeeds if the calls are cancelled while an attempt is in flight, or if they aren't cancelled at all.

This reproduces with the published 1.14.5 and 1.13.6 releases as well as master. For comparison, grpc-go aborts its backoff wait when the call's context is cancelled.

Fix

Store the retry timer. In cancelWithStatus(), set the state to NO_RETRY so no path can start a new attempt, and clear the retry and hedging timers.

Tests

test/test-retry.ts has a new Retries after cancellation block with two tests. Both fail on master and pass with this change:

  • A client-streaming call cancelled during backoff starts only one attempt.
  • After 3 unary calls are cancelled during backoff, a new call still succeeds against a server limited to 3 concurrent streams.

Across the rest of the grpc-js test suite, the only failures are the same ones master has in my environment: TLS fixture tests that fail with ee key too small on this OpenSSL version.

I found this with a formal model of the retry state machine and confirmed it with the tests above.

🤖 Generated with Claude Code

cancelWithStatus() reported the status and cancelled the child calls, but
left the retry state unchanged and the backoff timer running. When a call
was cancelled while a retry was waiting out its backoff, the timer still
started a new attempt, and the call kept retrying through its remaining
attempts after the application had cancelled it.

Those attempts are sent after reportStatus() has cleared the write buffer,
so a unary attempt carries headers but no message. The server never
responds, and the stream stays open until the connection closes, occupying
one of the server's concurrent streams. Enough of them block every new call
on the channel.

Track the retry timer, and on cancellation disable further attempts and
clear the retry and hedging timers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linux-foundation-easycla

linux-foundation-easycla Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: phyous / name: Philip Youssef (a738dfb)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants