Fix ClickHouseRawResult: surface in-band mid-stream server exceptions on the raw streaming path - #479
Fix ClickHouseRawResult: surface in-band mid-stream server exceptions on the raw streaming path#479polyglotAI-bot wants to merge 7 commits into
Conversation
…path When a query fails after the HTTP response is committed (e.g. a throwIf partway through a large result), ClickHouse appends an in-band exception block framed as `__exception__\r\n<tag>\r\n<message>...`. Two bugs kept the native reader from ever surfacing it: - ExceptionTagAwareStream searched for a contiguous `__exception__<tag>` marker, but the server writes a CRLF between `__exception__` and the tag, so TryExtractMidStreamException always returned null. - ClickHouseDataReader.Read() caught only EndOfStreamException, while a live truncated response surfaces as HttpIOException; its end-of-stream PeekChar probe also sat outside the try. Tolerate the server's CRLF marker framing and treat the truncation as an IOException so a mid-stream failure raises a ClickHouseServerException with the real server error instead of a bare HttpIOException/EndOfStreamException. Fixes: #476 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… on the raw streaming path
ExecuteRawResultAsync / ClickHouseRawResult handed the HTTP response body straight to
the caller, so a query failing after the 200 OK was committed (server appends an in-band
exception block, then drops the connection) surfaced as a System.Net.Http.HttpIOException
("The response ended prematurely") — or, with http_write_exception_in_output_format=1, the
raw __exception__ block spliced into the caller's data — instead of a ClickHouseServerException.
All four accessors (ReadAsStreamAsync, ReadAsByteArrayAsync, ReadAsStringAsync, CopyToAsync)
now consult the X-ClickHouse-Exception-Tag header and wrap the body with a new opt-in
throwAtEndOfStream mode on ExceptionTagAwareStream, reusing the in-band detector fixed in
#476/#477. The default passive-observer mode (native reader path) and the untagged raw path
are unchanged.
Fixes: #475
|
I would strongly prefer integration tests over the unit tests we have. Also not entirely sure about the surface, eg the ReadAsStringAsync() and ReadBytes paths... |
Resolves the conflict created when the stacked base PR #477 (cs#476, the shared ExceptionTagAwareStream CRLF-marker detector + ClickHouseDataReader fix) was squash-merged to main, auto-retargeting this PR (#479) to main. Resolution keeps main's reviewed #477 changes and reduces this branch's net delta to only the raw-path surface (issue #475): - ExceptionTagAwareStream.cs: main's corrected #477 comments preserved; the opt-in throwAtEndOfStream mode layered on top. - ClickHouseRawResult.cs: unchanged from this PR (main did not touch it). - CHANGELOG.md / RELEASENOTES.md: both #476 and #475 entries kept. - Test files: main's #477 tests kept; #475 raw-path + throwAtEndOfStream tests kept. Verified: git diff origin/main...HEAD equals the raw-path delta only; focused mid-stream suite green on net10.0 (47 passed, 0 failed).
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR extends the existing mid-stream exception detection (via X-ClickHouse-Exception-Tag) to the raw/custom-FORMAT result surface (ExecuteRawResultAsync → ClickHouseRawResult) by introducing an opt-in “throw at end-of-stream” mode in ExceptionTagAwareStream, and adds unit/integration coverage to ensure raw accessors surface ClickHouseServerException rather than HttpIOException.
Changes:
- Add
throwAtEndOfStreambehavior (and async read overrides) toExceptionTagAwareStreamso it can proactively throw a capturedClickHouseServerExceptionon EOF /IOException. - Wrap
ClickHouseRawResultresponse bodies withExceptionTagAwareStream(..., throwAtEndOfStream: true)when the exception-tag header is present, across all four accessors. - Add mock-based and live-server tests for raw-result mid-stream exceptions; update changelog + release notes.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| ClickHouse.Driver/Formats/ExceptionTagAwareStream.cs | Adds opt-in EOF/IOException-triggered throwing + async read overrides to surface mid-stream server exceptions for direct stream consumers. |
| ClickHouse.Driver/ADO/Readers/ClickHouseRawResult.cs | Wraps raw response content in ExceptionTagAwareStream when X-ClickHouse-Exception-Tag is present across all accessors. |
| ClickHouse.Driver.Tests/Formats/ExceptionTagAwareStreamTests.cs | Adds test matrix validating throw-at-EOF mode across sync/async read APIs and termination modes. |
| ClickHouse.Driver.Tests/ADO/MidStreamExceptionTests.cs | Adds mock and integration tests asserting raw-result accessors surface ClickHouseServerException mid-stream. |
| CHANGELOG.md | Documents the raw-result mid-stream exception fix. |
| RELEASENOTES.md | Documents the raw-result mid-stream exception fix. |
| if (bytesRead > 0) | ||
| { | ||
| RecordBytes(buffer.AsSpan(offset, bytesRead)); | ||
| return bytesRead; | ||
| } |
There was a problem hiding this comment.
Correct on the facts, and I've taken the second option you outlined (state the guarantee accurately) rather than adding filtering.
The wrapper observes bytes rather than withholding them, so a caller reading the stream incrementally — or a CopyToAsync destination — can hold part of the result, including the raw __exception__ block, before the exception is raised at end-of-stream. That is inherent to streaming: bytes already handed to the consumer cannot be recalled. Note the two buffered accessors are not affected — ReadAsByteArrayAsync/ReadAsStringAsync materialize the body internally and throw instead of returning it, so no partial or block-bearing body reaches the caller there.
I've corrected the changelog and release note, and added the caveat to the ReadAsStreamAsync/CopyToAsync XML docs so the guarantee is stated where callers will actually see it.
Byte-level filtering (buffer a marker-sized tail, truncate at the opening marker) remains a real option for a stronger guarantee. I've put it to @alex-clickhouse rather than build it speculatively, since he'd flagged this same surface as something he wasn't sure about — leaving this thread open for that decision.
…face @alex-clickhouse asked for integration tests over the mock-based unit tests and questioned the ReadAsStringAsync()/ReadAsByteArrayAsync() surface. AGENTS.md likewise says to strongly prefer tests that actually call the db. - Replace the mock-HTTP ClickHouseRawResultMidStreamMockTests with real-server coverage of all four accessors (stream, bytes, string, CopyTo), plus a successful-query contrast case. Verified these fail against the unpatched reader with the reported HttpIOException and pass with the fix. - Fix a repeat-read regression this PR had introduced: the buffered accessors disposed the response's cached content stream, so a second ReadAsByteArrayAsync()/ReadAsStringAsync() threw ObjectDisposedException where the untagged path returns the body again. Since the server sends X-ClickHouse-Exception-Tag on every response, that affected every raw result on 25.11+. The body is now buffered and the response-owned stream left open, restoring HttpContent's repeat-read behaviour, with a regression test. - Correct the changelog/release-note over-claim raised in review: the streaming accessors raise the exception at end-of-stream and do not retroactively filter bytes already handed to the caller. Same clarification added to the XML docs.
|
Thanks — both points were well placed, and the second one turned up a real bug. Integration tests. Replaced the mock-HTTP
I checked these actually have teeth: all four mid-stream cases fail against the unpatched reader with exactly the reported The only unit tests I kept are the lower-level The surface — you were right to poke at it. Exercising That matters more than it first looks: the server sends So the surface is now:
On the streaming pair (also Copilot's point): the wrapper observes bytes, it does not withhold them, so a caller parsing incrementally — or a One boundary worth flagging either way: detection needs the marker in plaintext, so on the |
… read On the tagged raw path, once a buffering accessor (ReadAsByteArrayAsync / ReadAsStringAsync) materializes the body into bufferedContent it has drained the underlying content stream to EOF. A subsequent ReadAsStreamAsync / CopyToAsync re-read response.Content — the now-exhausted stream — and ignored the buffer, returning an empty body. Serve those accessors from bufferedContent when present, so all four stay consistent with each other and with the untagged HttpContent path (which buffers once and re-serves). Addresses the Cursor Bugbot review on #479.
…body
On the tagged raw path (ExecuteRawResultAsync -> ClickHouseRawResult) a
streaming accessor (ReadAsStreamAsync/CopyToAsync) consumes the underlying
single-consumption content stream. A subsequent re-materializing read
(ReadAsByteArrayAsync/ReadAsStringAsync/CopyToAsync) re-read the
already-drained stream and returned/cached only the bytes left after the
drain -- a silently truncated body. Untagged HttpContent throws
InvalidOperationException ("The stream was already consumed. It cannot be
read again.") in this ordering; mirror that instead of handing back a partial
result the caller cannot distinguish from a complete one.
Track whether the content stream has been vended and throw the same
InvalidOperationException from the re-materializing accessors once it is,
without eagerly buffering (per AGENTS.md "avoid buffering entire responses").
Re-requesting the stream itself still continues reading, matching untagged
HttpContent. A buffering accessor that ran to completion first still serves
its cached body.
Addresses the cursor[bot] review on PR #479.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7b89e2b. Configure here.
…w-midstream-exception # Conflicts: # CHANGELOG.md # ClickHouse.Driver/Formats/ExceptionTagAwareStream.cs # RELEASENOTES.md
|
Rebase/merge housekeeping: Conflicts and how they were resolved:
Verification after the merge (net10.0, devbox ClickHouse server): build 0 errors; the focused mid-stream / raw-result / No behavioral change was introduced by the merge beyond the union described above. |

Description
Fixes #475.
ExecuteRawResultAsync→ClickHouseRawResulthanded the HTTP response body straight to the caller.When a query fails after the
200 OKis committed and rows are already streaming (e.g. athrowIfpartway through a large
FORMAT CSV/JSONEachRow/Arrow/Parquetresult), the server appends anin-band exception block to the body and drops the connection. The raw path never consulted the
X-ClickHouse-Exception-Tagheader, so callers saw aSystem.Net.Http.HttpIOException("The response ended prematurely") — or, with
http_write_exception_in_output_format=1, the raw__exception__block spliced into their data — instead of aClickHouseServerException. The nativeClickHouseDataReaderpath already handles this; the raw path did not.Changes
ExceptionTagAwareStream— add an opt-inthrowAtEndOfStreammode (plus true-asyncReadAsyncoverrides) that proactively raises the captured
ClickHouseServerExceptionwhen the inner stream ends(a 0-byte read) or drops (
IOException) with an in-band block in the observed tail. The default(
false) passive-observer mode used by the native reader is unchanged.ClickHouseRawResult— when theX-ClickHouse-Exception-Tagresponse header is present, wrap thebody with
throwAtEndOfStream: trueacross all four accessors (ReadAsStreamAsync,ReadAsByteArrayAsync,ReadAsStringAsync,CopyToAsync). Both raw entry points(
ClickHouseClient.ExecuteRawResultAsync,ClickHouseCommand.ExecuteRawResultAsync) funnel throughthe single
ClickHouseRawResultconstructor, so both are covered. When the header is absent theaccessors are byte-for-byte unchanged;
ReadAsStringAsyncpreserves the framework's charset/BOM decoding.Test
ClickHouseRawResultMidStreamMockTests(no server): all four accessors raiseClickHouseServerExceptionon an in-band block; all four return the full body when the tag is presentbut the query succeeded; the untagged path returns the body verbatim (even with
__exception__bytes present).ExceptionTagAwareStreamTests: parametrized over {syncRead,ReadByte, asyncReadAsync(byte[]),async
ReadAsync(Memory)} × {clean EOF, dropped connection}; a large-prefix (>4 KiB) case; and a contrastcase confirming the default passive mode still never throws on its own.
ClickHouseRawResultMidStreamTests.ExecuteRawResultAsync_MidStreamException_SurfacesServerException(
[FromVersion(25, 11)]): drives a real committed-then-failed streamedFORMAT CSVquery against a liveserver and asserts
ExecuteRawResultAsyncsurfaces the server's error.Focused suite (mid-stream + exception-tag + raw-result + accept-encoding) is green on net10.0 (78 passed).
Pre-PR validation gate
ClickHouseRawResultconstructor, covering both entry points)