fix(csharp): always clone request before send to prevent ObjectDisposedException on retry#16459
Conversation
…edException on retry Restructure RawClient.SendWithRetriesAsync so the retryable-content path always sends a clone, never the original HttpRequestMessage. Under HTTP/2, HttpClient disposes request.Content after sending, which broke CloneRequestAsync on retry. Also: clamp negative MaxRetries to 0; short-circuit MaxRetries=0 and non-retryable content through a no-clone fast path; thread CancellationToken into CloneRequestAsync's body buffering.
There was a problem hiding this comment.
🚩 Seed test outputs not updated to reflect template changes
The REVIEW.md states: "When a template file changes, corresponding seed test outputs should also be updated (either via seed test or bulk update)." The RawClient.Template.cs and RetriesTests.Template.cs templates were modified, but the generated copies in seed/csharp-sdk/*/src/*/Core/RawClient.cs and seed/csharp-sdk/*/src/*.Test/Core/RawClientTests/RetriesTests.cs still contain the old code (e.g., no Math.Max, no ContentDisposingHandler class, no cancellation token in CloneRequestAsync). These should be regenerated before merge.
Was this helpful? React with 👍 or 👎 to provide feedback.
SDK Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on Full benchmark table (click to expand)
main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via |
…thRetriesAsync The fast-path block declares `var response` inside the `if (!isRetryableContent || maxRetries == 0)` branch; the retry loop declared `HttpResponseMessage? response = null` in the enclosing method body. C# treats the entire method as one declaration space, so the two `response` locals collide with CS0136 even though their scopes don't overlap. Rename the outer one to `retryResponse`.
Regenerates RawClient.cs and RetriesTests.cs (plus .fern/metadata.json bumps) for all 178 csharp-sdk fixtures to pick up the SendWithRetriesAsync rewrite, the CloneRequestAsync CancellationToken plumbing, and the new ContentDisposingHandler-based regression tests.
…etries code The verbose multi-line comments in SendWithRetriesAsync and RetriesTests ship into every generated SDK and multiply across all customer repos. Reduce the load-bearing WHY to two lines in RawClient and one each in the regression tests; the rest is in the changelog and the git history.
Closes FER-11195
Summary
ObjectDisposedException: Cannot access a disposed object. Object name: 'System.Net.Http.StringContent'fromRawClient.CloneRequestAsyncon the first retry of a JSON request.SendWithRetriesAsyncsent the originalHttpRequestMessagefor the initial attempt and only cloned for subsequent retries. Under HTTP/2 (and some other configurations),HttpClientdisposes the Content of every request it sends, so the original's Content was already disposed by the time the retry loop tried to clone it.MaxRetriesto 0; short-circuitMaxRetries == 0and non-retryable content to a no-clone fast path; threadCancellationTokenintoCloneRequestAsync's body buffering; new multi-retry regression test using aDelegatingHandlerthat disposesrequest.Contentafter send.Customer stack trace
Before vs after
Before — original sent on attempt 0, cloned only for retries; first send disposed the original's Content:
After — fast path for opt-out cases; always-clone loop for retryable content:
Why our existing test missed it
SendRequestAsync_ShouldPreserveJsonBody_OnRetry(added in the prior fix #12914) exercises POST JSON → 500 → 200 against WireMock — exactly the failing scenario. But WireMock loopback HTTP/1.1 +SocketsHttpHandlerdoesn't disposerequest.ContentafterSendAsyncreturns, so the test passes whether the bug is present or not. I verified this with a diagnosticDelegatingHandlerthat captured the disposal state.The new regression test wraps
HttpClientHandlerin aContentDisposingHandlerthat explicitly disposesrequest.Contentafterbase.SendAsyncreturns — reproducing production HTTP/2 behavior reliably. Confirmed: without this PR's fix, the new test throws the exact customer stack; with the fix, all 16 RetriesTests pass.Test plan
seed/csharp-sdk/bytes-upload, randotnet test --filter RetriesTests→ 16/16 passObjectDisposedExceptionatCloneRequestAsync → CopyToAsync(matches customer stack exactly)update-seed.ymlregenerates all 136 csharp-sdk fixtures, runs the full test suite🤖 Generated with Claude Code