fix(http): stream response bodies - #3566
Conversation
There was a problem hiding this comment.
Review: fix(http): stream response bodies (#3566)
Nice direction overall — switching to ResponseHeadersRead and replacing the old LoadIntoBufferAsync full-body buffering with a bounded preview + replay stream is exactly the right shape for fixing the download-memory problem. I found two issues in the new preview/replay path that undercut the goal, plus a couple of smaller notes.
1. (High / correctness) MaxBodySizeToLog <= 0 ("disable truncation") permanently drains the body stream — reintroduces the bug this PR is fixing
HttpContentPreviewReader.ReadAsync (src/ModularPipelines/Http/HttpContentPreviewReader.cs:59-63):
if (maxBytes <= 0 || maxBytes == int.MaxValue)
{
var unboundedBody = await content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return (unboundedBody, false, content, content.Headers.ContentLength);
}This branch reads the content fully via ReadAsStringAsync, then returns the same original content reference as ReplayContent. For live network-backed HttpContent (a real response, not something like StringContent), the underlying stream is single-pass — once ReadAsStringAsync drains it, there is nothing left to replay. Setting response.Content = replayContent is a no-op (same object), so the "replay" guarantee silently doesn't hold here.
MaxBodySizeToLog is documented as "Set to 0 or negative to disable truncation" (HttpLoggingOptions.cs:72) — i.e. "log the whole body, no size cap," not "skip preview/replay entirely." That's a realistic, reachable configuration (e.g. someone wants full-body logging for a JSON API call), and any code that reads response.Content afterwards (a module reading ReadFromJsonAsync, DownloadFileAsync, or EnsureSuccessStatusCodeWithContentAsync on a later failure) will get an empty/short body. This is the same class of bug as the original issue (#1610) this PR is closing, just reintroduced for the "no truncation" configuration.
Suggested fix: make the unbounded branch go through the same buffer-and-wrap strategy as the bounded branch (read into a growable buffer / MemoryStream, then wrap in the same kind of prefix-replay stream) instead of special-casing it to return the original content untouched.
2. (High / security) Secrets that straddle the preview boundary are no longer obfuscated
Previously (HttpResponseFormatter/HttpRequestFormatter, pre-PR): the full body was read, obfuscated, and only then truncated to maxBodySize characters for display — so a secret appearing anywhere in the displayed prefix was always matched against the complete string before slicing.
Now: HttpContentPreviewReader.ReadAsync reads only the first maxBytes + 1 raw bytes, and obfuscation runs afterwards on that truncated preview (AppendBodyAsync in both formatters: _secretObfuscator.Obfuscate(body, null) where body is already cut short). SecretObfuscator.Obfuscate (src/ModularPipelines/Engine/SecretObfuscator.cs) does exact substring matching against known secret values — if a secret value in the body starts inside the preview window but extends past maxBytes (e.g. an API key embedded in a JSON response, truncated mid-token by the default 4096-byte window), the exact match fails on the incomplete fragment and the partial secret is printed to logs unmasked.
Given "Automatic secret obfuscation in logs" is called out as a core guarantee of this library, this is a meaningful regression trade for the memory fix. Options to consider:
- Read a bit more than the display window (e.g.
maxBytes + longestKnownSecretLength) before obfuscating, then truncate the obfuscated text down tomaxBytesfor display — restores the old guarantee for secrets that start within the display window, at a small bounded memory cost. - At minimum, flag this trade-off in the
MaxBodySizeToLogdoc comment so users aren't surprised.
No test currently exercises this boundary case — the new HttpFormatterTests use a no-op obfuscator mock, so this wouldn't have been caught.
Minor notes
HttpContentPreviewReader.ReadAsyncallocatesnew byte[maxBytes + 1]up front — ifMaxBodySizeToLogis misconfigured to a very large value this allocates that much memory eagerly per call. Not a blocker, just worth a sanity comment/cap given the whole point of this PR is bounding memory use.PrefixReplayStream/HttpContentPreviewReaderdisposal chain (disposingownedContentcascades to the underlying stream) looks correct, and the binary-content short-circuit (skipping preview entirely for non-text content types) is a good call — it's what makes real file downloads with response-logging opted-in safe today.
What's good
ResponseHeadersReadchange inHttp.csis correct and covered by a real test (SendAsync_ReturnsAfterHeadersWithoutBufferingResponseBody) that blocks on the body stream to prove headers return first.- Removing the blanket
LoadIntoBufferAsyncfromResponseLoggingHttpHandleris right given the new per-formatter replay mechanism. - Flipping
DownloadOptions's defaultLoggingTypeto dropResponseis a sensible default-safety improvement for downloads. - Bounded preview + replay design for the common (
maxBytes > 0) path is solid and well tested (HttpFormatterTests).
1c2c6b8 to
2ca4846
Compare
|
Rebased onto current Patch equivalence preserved ( @codex review |
Code reviewI compared the current commit ( 1. (High / correctness)
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2ca4846896
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
2ca4846 to
9803a94
Compare
|
Addressed the current review findings in
Validation: @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fcb99edaff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Code review
I compared the current head (fcb99edaff) against the two high-severity issues raised in the earlier reviews on this PR (unbounded-replay draining the stream, and secrets straddling the preview truncation boundary).
Previously flagged issues — now resolved
1. Unbounded (MaxBodySizeToLog <= 0) replay — I verified this empirically: HttpContent.ReadAsStringAsync() does internally buffer the content (a minimal repro against a genuinely non-seekable, forward-only Stream-backed StreamContent showed a second ReadAsStreamAsync()/ReadAsStringAsync() after the first still returns the full original body). So returning the same content reference as ReplayContent in HttpContentPreviewReader.cs L21-L25 is correct — this concern doesn't reproduce.
2. Secrets straddling the preview boundary — now fixed via the new HttpBodySecretRedactor.RedactPartialSecretAtBoundary (HttpBodySecretRedactor.cs), which detects a known-secret suffix match at the truncation boundary and masks it before the normal obfuscator runs. Covered by new tests (ResponseFormatter_RedactsSecretCrossingPreviewBoundary, RequestFormatter_RedactsSecretCrossingPreviewBoundary). Looks correct.
Also worth calling out: the new TimeoutHttpContent/TimeoutReadStream wrapper (Http.cs) is a good catch on the author's part — switching to ResponseHeadersRead meant the timeout CancellationTokenSources would previously be disposed (via using) as soon as SendAsync returned, silently disabling the configured timeout for anything that reads the body later. The ownership hand-off in WrapResponse/the finally block is correct, and it's covered by SendAsync_ConfiguredTimeoutCancelsStreamedBody.
New issue found in this round
1. (High / correctness) Replacing request.Content with a non-seekable replay stream breaks HTTP redirects and auth-challenge retries — reachable with default settings
HttpRequestFormatter.cs L162-L165:
var (body, isTruncated, replayContent, totalLength) = await HttpContentPreviewReader
.ReadAsync(content, maxBodySize, cancellationToken)
.ConfigureAwait(false);
request.Content = replayContent;replayContent is a StreamContent wrapping PrefixReplayStream, and PrefixReplayStream.CanSeek => false. I verified directly that StreamContent over a non-seekable stream throws InvalidOperationException: "The stream was already consumed. It cannot be read again." the second time it's serialized.
Before this PR, AppendBodyAsync never reassigned request.Content — it just read the body via ReadAsStringAsync() and left the original (often seekable/re-buffered) content in place. Now, for the default configuration (LogRequestBody = true, MaxBodySizeToLog = 4096 > 0, so the bounded path runs for any request with a body), request.Content is swapped for a stream that can only be serialized once.
Concretely: POST/PUT a body to an endpoint that responds 307/308 (which — unlike 301/302/303 — preserve the method and body and are auto-retried by SocketsHttpHandler when AllowAutoRedirect is on, the default), or one that issues an NTLM/Negotiate/Digest challenge requiring the body to be resent. HttpClient re-serializes the same HttpContent instance for the retry and throws instead of following the redirect/challenge. This is the exact same class of bug (single-pass stream where a replay is required) the PR is fixing on the response side, just newly introduced on the request side by applying the same replay-stream pattern there.
Suggested direction: for the request-formatter path specifically, prefer a seekable buffer (e.g. MemoryStream-backed replay content, or rewind-capable content) so the underlying HttpContent can seek back to the start on retry, rather than the forward-only PrefixReplayStream used for the response side.
Minor / worth a look
TimeoutHttpContent (TimeoutHttpContent.cs L26-L58) only overrides the async SerializeToStreamAsync/CreateContentReadStreamAsync overloads. I confirmed with a minimal repro that an HttpContent subclass that only overrides the async overloads throws NotSupportedException from the synchronous SerializeToStream/ReadAsStream/CopyTo APIs (the base class's fallback). So whenever a timeout is configured (HttpOptions.Timeout or PipelineOptions.DefaultHttpTimeout), any caller that reads response.Content synchronously instead of via the *Async APIs will get a NotSupportedException where it previously worked. Narrower than the issue above (needs a configured timeout + sync content access), but worth closing while this file is fresh.
No CLAUDE.md compliance issues found in the changed files.
|
Addressed the latest review findings in
Focused regression tests pass, scoped formatting/analyzers pass, and @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 99389806ea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code reviewCompared the current head ( Fixed since the last review
All three are well-targeted and test-covered. Still open from the last two review rounds — not touched by this pushI diffed 1. (High / correctness)
This is worth calling out as more than a one-line fix: the request side and response side have different replay requirements, and this PR's 2. (Minor, carried over) Any caller that reads a timeout-wrapped response's content via the synchronous Since both were already raised twice on this PR and the touched files are byte-for-byte identical to the last-reviewed commit, it's worth double-checking these weren't simply missed when triaging this round's fixes, since the PR description implies they were folded into this round's addressed items. |
9938980 to
935e635
Compare
|
Addressed the latest timeout findings in
Focused timeout regressions pass, scoped formatting/analyzers pass, and the core Release build passes with 0 errors (227 existing warnings). @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 935e635d2e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Code review
Reviewed the current head (935e635, "fix(http): preserve client body timeouts"). I diffed it against the previous reviewed commit (0ca19a6) to scope what's new this round, and re-checked the file state directly for issues raised in earlier rounds.
Still open — raised twice already, unaddressed for the third round in a row
(High / correctness) request.Content is still replaced with a non-seekable stream, still breaking redirects/auth retries for any request with a body under default settings
src/ModularPipelines/Http/HttpRequestFormatter.cs:165 still does request.Content = replayContent;, where replayContent wraps PrefixReplayStream (HttpContentPreviewReader.cs:113, CanSeek => false, hardcoded). I confirmed via git diff that this round's commit only touched Http.cs, TimeoutHttpContent.cs, and HttpTests.cs — HttpRequestFormatter.cs and HttpContentPreviewReader.cs are byte-for-byte unchanged since this was first flagged.
This runs by default (MaxBodySizeToLog = 4096 > 0 is the bounded/common path) for every request that has a body, and AllowAutoRedirect defaults to true. A 307/308 redirect or an NTLM/Negotiate/Digest challenge that needs to resend the same body will hit a forward-only stream that's already been drained, and throw — the same class of single-pass-stream bug this PR is fixing on the response side, reintroduced on the request side. As noted previously, the request side doesn't have the memory-pressure constraint that motivates the response side's bounded prefix+tail design (request bodies here are already capped by MaxBodySizeToLog), so a seekable buffer (e.g. read into a byte array / MemoryStream-backed content, the way ResilienceHttpHandler.SendAsync already does for retries) would fix this correctly instead of reusing the response-side replay stream for a use case it doesn't fit.
Given this has now survived three separate "addressed the latest review findings" rounds without the flagged files changing at all, it'd be worth confirming this is intentionally deferred rather than being missed during triage.
This round's changes (Http.cs, TimeoutHttpContent.cs) — solid, with one fragility worth flagging
Http.cs: falling back to theHttpClient's own (finite)Timeoutwhen neitherHttpOptions.TimeoutnorPipelineOptions.DefaultHttpTimeoutis set is the right fix — sinceResponseHeadersReadwas introduced,HttpClient.Timeoutonly protects the headers phase internally, so without this the client's configured timeout would silently stop covering body reads. Priority ordering and disposal (ownership handoff toTimeoutHttpContentinWrapResponse) look correct.TimeoutHttpContent.CreateContentReadStream(CancellationToken)(the missing sync overload flagged in an earlier round) is now implemented — that gap is closed.- The new "interrupt a blocked synchronous
Read/CopyToby disposing the stream on timeout" mechanism (TimeoutReadStream's_timeoutRegistration) is a reasonable approach for synchronous reads, which don't take aCancellationToken, but it leans on catchingObjectDisposedExceptionspecifically (TimeoutHttpContent.cs:144-148, 158-162) to turn the disposal into a cleanOperationCanceledException. That's only reliable if disposing the real underlying transport stream mid-read reliably throwsObjectDisposedException. For actualSocketsHttpHandlerresponse streams (chunked/HTTP2 read streams,SslStream, etc.), racing aDispose()against an in-flight blockingRead()can just as plausibly surface anIOException(sometimes wrappingObjectDisposedException, sometimes not) — in which case this catch doesn't trigger and a raw, less-friendly exception leaks out instead of the intendedOperationCanceledException.
The new test (SendAsync_ConfiguredTimeoutInterruptsSynchronousBodyRead) doesn't exercise this against a real network stream — it usesBlockingReadStream, a test double whoseDispose()sets a flag and whose blockedReadexplicitly doesif (_isDisposed) throw new ObjectDisposedException(...). That's asserting the mock behaves the way the mock was written to behave, not that a real HTTP body stream reacts to disposal the same way. Worth either widening the catch to also handleIOException(checking the timeout token in the filter, same as today) or, if real-stream behavior has actually been verified againstSocketsHttpHandler, a comment saying so would help the next reader trust the narrow catch.
What's good (carried from earlier rounds, still holds)
ResponseHeadersReadinHttp.cs+ secret-boundary redaction (HttpBodySecretRedactor) + bounded preview/replay design for the response side — solid and well tested.- Retry-response disposal in
ResilienceHttpHandler, cancellation propagation through logging, and complete-character preview decoding are all in place and covered.
Net: the timeout hardening in this specific commit is a good, mostly well-tested improvement, but the pre-existing High-severity request-body-replay issue is still the blocker for merge, and it hasn't been touched across three fix rounds now.
|
Addressed all fresh review findings in
Validation: @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 036aecbc83
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code reviewReviewed the current head ( Previously flagged High-severity issues — all now resolved1. Request body replaced with a non-seekable stream, breaking redirects/auth retries (raised 3 rounds in a row) — fixed. 2. Secrets straddling the preview truncation boundary going unmasked — fixed via 3. Unbounded ( This round's other change — response wrapping order in
|
036aecb to
41d6a40
Compare
|
Rebased onto current Validation:
@codex review |
There was a problem hiding this comment.
Code review
Re-reviewed the current head after this round's push, which the PR description/comments indicate was a rebase-only update onto current main (aggregate patch-id preserved: a47539b1e1f644acf709ff209f0150fadce39688). I diffed the touched files directly against the last reviewed commit (036aecbc83, which received a "good to merge" verdict) rather than trusting the claim — Http.cs, HttpContentPreviewReader.cs, HttpBodySecretRedactor.cs, HttpRequestFormatter.cs, HttpResponseFormatter.cs, TimeoutHttpContent.cs, ResilienceHttpHandler.cs, and ResponseLoggingHttpHandler.cs are unchanged in substance, only rebased.
Status of previously-flagged issues (all resolved, confirmed still fixed)
- Unbounded (
MaxBodySizeToLog <= 0) replay draining the stream irreplayably — confirmed non-issue;HttpContent.ReadAsStringAsync()buffers internally so the same-instance replay inHttpContentPreviewReader.ReadAsync(lines 284-289) holds. - Secrets straddling the preview truncation boundary going unmasked — fixed via
HttpBodySecretRedactor.RedactPartialSecretAtBoundary, which matches a secret's prefix against the tail of the truncated preview before the normal obfuscator runs, and is covered by dedicated boundary tests. - Request body replaced with a non-seekable stream, breaking redirects/auth retries — fixed by routing the request side through
HttpContentPreviewReader.ReadReplayableAsync, which fully buffers into aByteArrayContent(seekable/re-serializable) instead of reusing the response-side's forward-onlyPrefixReplayStream. This is the right design: the response side has a genuine memory-pressure reason for a bounded, single-pass prefix+tail stream (large downloads), while the request side doesn't (bodies are already capped byMaxBodySizeToLog), so giving the two sides different replay strategies instead of forcing one abstraction to fit both is the correct call here. - Cancellation propagation through logging, retry-response disposal in
ResilienceHttpHandler, complete-character preview decoding at UTF-8 boundaries, and synchronous-read timeout normalization (IOException/ObjectDisposedException→OperationCanceledException) — all previously verified and unchanged.
Standing minor/non-blocking note (carried over, not a blocker)
HttpRequestFormatter and HttpResponseFormatter still duplicate a fair amount of logic (AppendHeaders, IsSensitiveHeader, IsBinaryContent, FormatSize, AppendTruncationMessage) beyond what HttpBodySecretRedactor now shares. This PR's multi-round history is itself good evidence for extracting these into a shared internal helper: several of the fixes here (secret-boundary redaction, truncation messaging) had to be threaded through both copies in lockstep, which is exactly the kind of drift a shared type would prevent for the next similar change. Not worth blocking on, since behavior is currently correct and identical between the two, but worth a follow-up if this file sees more churn.
Overall
Good shape to merge. The design cleanly separates concerns that looked like they could share one abstraction but didn't (response-side bounded streaming vs. request-side full-buffer replay), and the back-and-forth across review rounds is reflected in solid, targeted test coverage for each fix (HttpFormatterTests, HttpTests, the downloader logging-default regression). No new issues found in this round.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 41d6a40ca9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
9c74c85 to
102e603
Compare
|
Rebased onto current main; HTTP, formatter, and downloader suites pass (46/46). @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 102e603926
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Bound logging previews so response logging no longer defeats streaming. Refs #3467.
Keep configured cancellation alive until response content disposal and mask partial secrets at bounded preview edges. Refs #3467
Document and verify that unbounded ReadAsStringAsync buffering keeps request and response content readable. Refs #3467
Propagate cancellation through response logging, dispose retry responses, and decode complete preview characters before secret-boundary redaction. Refs #3467
Carry finite HttpClient.Timeout values into streamed content and dispose the underlying stream when timeout cancellation must interrupt synchronous reads. Refs #3467
102e603 to
2bab96c
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2bab96cccb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Code reviewReviewed commit 1.
|
|
@codex review |
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Closes #3467.
Streams HTTP response bodies by using
ResponseHeadersRead, keeps request/default/HttpClient.Timeoutcancellation alive through body consumption, interrupts synchronous and asynchronous reads, and preserves bounded response-body validation for non-success responses.Cancellation failures from disposed body streams are normalized to
OperationCanceledExceptionfor configured timeouts, caller cancellation, and per-read cancellation tokens.Validation:
HttpTests: 31/31 passedAtomic download follow-up
n-DownloaderTests`: 8/8.