Skip to content

Deliver SSE filter failures as parseable events - #6085

Closed
JAORMX wants to merge 1 commit into
mainfrom
envelope-sse-delivery
Closed

Deliver SSE filter failures as parseable events#6085
JAORMX wants to merge 1 commit into
mainfrom
envelope-sse-delivery

Conversation

@JAORMX

@JAORMX JAORMX commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Why. When response filtering fails on the SSE path, ResponseFilteringWriter.writeErrorResponse writes a bare JSON object into the text/event-stream body — no data: prefix, no blank-line terminator. Under the WHATWG event-stream grammar that line parses as an unrecognized field (named {"jsonrpc" up to the first colon) and is silently discarded: the client never sees the error at all, only a stream that ends with no response, and hangs until its own timeout. Two compounding problems live in the same function: the WriteHeader(500) is usually a no-op (earlier stream writes and the proxy's Flush have already committed the 200/text/event-stream headers, so Go logs superfluous response.WriteHeader call), and the stop=true early return skips the line-separator reconciliation whose own comment says it exists to keep SSE parsing intact — so truncation can strand a partial event.

#6066 made this MORE deceptive, not fixed. It rewrote writeErrorResponse so the payload on this path is now conformant JSON-RPC (-32603, scrubbed message) — while remaining framed as a bare JSON object no event-stream parser will ever dispatch. Conformant bytes are not delivery. A reviewer glancing at that code post-#6066 will conclude it is fine; #6055's reviewer notes predicted exactly this trap ("please don't take the green bytes there as evidence the path works"). The delivery, not the payload, is what this PR fixes.

The decision (#6037 poses it explicitly: emit a framed error event, or abandon the stream?): emit the event, then end the stream. The Streamable HTTP transport expects the SSE stream to eventually carry the JSON-RPC response for the POSTed request; an error response is a response; and the reference TypeScript SDK's SSE loop parses error responses for completion detection — so a framed, correlatable error is strictly better than a close the client cannot distinguish from success. The filter has already failed closed by this point; what the client deserves is the ability to stop waiting and know why.

How: new writeSSEError frames the error as a fully self-terminated event —

  • a leading separator first terminates any partially written preceding event, so the error dispatches standalone (fixes the truncation interaction: a partial data: line can no longer strand the error inside another event's buffer); at an event boundary the blank line is a spec-level no-op;
  • the payload rides a data: field line, followed by the blank line that dispatches it;
  • no mid-stream status rewrite — the WriteHeader(500) is gone from the SSE path (the JSON path keeps it, unchanged);
  • the stop=true early return is now safe by construction, because the frame does not depend on the loop's later reconciliation.

The scrub semantics from #6066 are preserved: the shared filterErrorBody emits the generic -32603 "internal error" body, and both writers log the underlying error server-side only.

Closes #6037 (all three of the issue's components: delivery framing, the mid-stream WriteHeader, the truncation/linesep interaction).

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests — go test ./pkg/authz green (plus ./pkg/mcp ./pkg/webhook/... for the neighborhood). task test not used locally (known gotestfmt v2.5.0 panic); CI runs the real thing.
  • Linting (task lint-fix) — 0 issues.
  • Manual testing (describe below)

The guard deliberately refuses to accept conformant bytes as evidence. TestWriteSSEErrorIsParseableByAnSSEClient runs the written bytes through a strict WHATWG-grammar SSE parser (parseSSEStream, implemented per the spec's "interpreting an event stream" rules: unrecognized fields ignored, dispatch only on a blank line with a non-empty data buffer, undispatched data discarded at EOF) and requires exactly one dispatched, default-typed event whose data is the conformant JSON-RPC error — across LF and CRLF streams, with int64/string ids echoed and an unrecoverable id omitted (never "id":null). TestWriteSSEErrorAfterPartialEvent pins the truncation fix (the error parses standalone after an undispatched data: or event: line). The companion TestBareJSONInEventStreamYieldsNoSSEEvent feeds the parser exactly what the pre-fix code writes and asserts zero events — documenting the failure mode and guarding the guard's strictness.

Proven fail-before/pass-after: with writeSSEError's body reverted to the pre-fix behavior (WriteHeader(500) + bare write), both delivery tests fail; restored, the package is green. The recorder also pins that no status rewrite is attempted mid-stream.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Changes

File Change
pkg/authz/response_filter.go filterErrorBody (shared body builder, #6066 scrub preserved); writeErrorResponse scoped to the JSON path with a do-not-use-on-SSE warning; new writeSSEError; writeSSEDataLine takes the stream's separator and routes error paths to it
pkg/authz/response_filter_test.go WHATWG-grammar parseSSEStream helper + the three delivery/truncation/zero-event guards

Does this introduce a user-facing change?

Yes. When response filtering fails mid-SSE-stream (e.g. a backend frame smuggling a result outside a clean Response), clients now receive a parseable JSON-RPC error event carrying the request id, instead of a silently truncated stream that forces them to hang until their own timeout.

Special notes for reviewers

  • The SSE stream ends after the error event (the caller stops processing) — per the transport spec, the final message on the stream may be the response, and an in-band error the client can correlate beats an unexplained close.
  • writeSSEError returns nil once the event is written: the failure has been delivered in-band and logged server-side; only a write failure propagates.
  • The sibling SSE loop in pkg/mcp/tool_filter.go (which processSSEResponse was originally adapted from) does not share this bug's trigger surface the same way and is deliberately not touched here — Stop splicing whole SSE buffers into error text #6079 recently swept the error-text side of that file; if its delivery paths need the same treatment, that is its own change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

When response filtering failed on the SSE path, writeErrorResponse wrote
a bare JSON object into the text/event-stream body with no 'data: '
prefix and no blank-line terminator. Under the WHATWG event-stream
grammar that parses as an unrecognized field and is silently discarded,
so the client never saw the error at all — it saw a stream that simply
ended with no response and hung until its own timeout. Two compounding
problems lived in the same function: the WriteHeader(500) was usually a
no-op because earlier stream writes had already committed the headers,
and the stop=true early return skipped the line-separator reconciliation
that exists to keep SSE parsing intact.

Of the options #6037 poses (emit a framed error event, or abandon the
stream), emit the event: the Streamable HTTP transport expects the SSE
stream to eventually carry the JSON-RPC response for the POSTed request,
an error response is a response, and the reference TypeScript SDK's SSE
loop parses error responses for completion detection — so a framed,
correlatable error is strictly better than a close the client cannot
tell from success. The filter has already failed closed by this point;
what the client deserves is the ability to stop waiting and to know why.

writeSSEError frames the error as a self-terminated event: a leading
separator first terminates any partially written preceding event (fixing
the truncation interaction — a partial 'data:' line can no longer strand
the error in another event's buffer), then 'data: <error>' and a blank
line to dispatch it. No status rewrite is attempted mid-stream. The
JSON path keeps writeErrorResponse unchanged.

The delivery guard runs the written bytes through a WHATWG-grammar SSE
parser and requires exactly one dispatched, default-typed event whose
data is the conformant error — because after #5950 the payload alone
LOOKS correct while remaining invisible to any SSE client, conformant
bytes are deliberately not accepted as evidence here. A companion test
pins that the pre-fix shape yields zero events under the same parser.

Fixes #6037

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

Rebased onto post-#6066 main: filterErrorBody adopts #6066's scrubbed
generic message and server-side logging (the error parameter now feeds
the log only), and writeSSEError logs at the same Error level. The
delivery fix itself is unchanged — #6066 made the SSE-path payload
conformant and scrubbed but left it framed as a bare JSON object no
event-stream parser dispatches.
@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label Jul 28, 2026
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.81818% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.26%. Comparing base (d79c3a9) to head (f54a164).

Files with missing lines Patch % Lines
pkg/authz/response_filter.go 81.81% 3 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6085      +/-   ##
==========================================
- Coverage   72.28%   72.26%   -0.03%     
==========================================
  Files         728      728              
  Lines       75514    75530      +16     
==========================================
- Hits        54588    54579       -9     
- Misses      17002    17045      +43     
+ Partials     3924     3906      -18     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JAORMX

JAORMX commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #6087, which fixes the same bug (#6037) and was opened independently in parallel.

Having compared the two: #6087's approach is the better fix. It emits the error as a substitute data: line in place of the failed frame and continues the loop, so it never needs the leading-separator write this PR used — which means it avoids the partial-event dispatch this version can cause (a preceding half-written event getting flushed to the client when the separator terminates it). Same destination, cleaner route.

The test assets from this PR that are independent of the fix's shape — the strict WHATWG-grammar SSE parser, the delivery guard built on it, and the zero-events companion proving the pre-fix shape is never dispatched — are offered on #6087 for the author to take or leave.

No action needed from reviewers here. The stacked #6086 has been rebased off this branch and retargeted to main; it no longer depends on this PR.

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

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Filter-failure errors on the SSE path are silently discarded

2 participants