Conversation
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>
|
|
murgatroid99
approved these changes
Sep 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When a call with a
retryPolicyis cancelled while a retry is waiting out its backoff,RetryingCallkeeps retrying.cancelWithStatus()reports the status and cancels the child calls, but it leavesstateas'RETRY'and doesn't clear the backoff timer (the timer handle isn't stored). When the timer fires, it callsstartNewAttempt()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:
maxAttempts).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'sMAX_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 withDEADLINE_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 toNO_RETRYso no path can start a new attempt, and clear the retry and hedging timers.Tests
test/test-retry.tshas a newRetries after cancellationblock with two tests. Both fail onmasterand pass with this change:Across the rest of the grpc-js test suite, the only failures are the same ones
masterhas in my environment: TLS fixture tests that fail withee key too smallon 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