fix: emit clean client error instead of bricking the session when the streaming validator detects a corrupted outbound stream - #800
Conversation
… outbound stream When the streaming protocol validator detects an out-of-order event (e.g. content blocks after message_delta, the PR #356 bug class), the pipeline now withholds the corrupting event, emits a structured SSE error event the client can parse, and ends the stream, instead of silently forwarding the corruption and letting the client brick the session with a malformed assistant message. - Add incremental StreamingProtocolValidator (observe per event + finalize) to stream_protocol_validator.py; the batch validate_anthropic_event_ordering is now a wrapper over it so both share one rule set. - Enforce per-event rules inline in _handle_execution_streaming; end-of-stream rules (message_stop last, blocks closed) stay advisory log-and-warn since they are only decidable after delivery. - Webhook fires with success=False/http_status=500 on protocol abort; empty-stream error suppressed on the abort path (exactly one error event per failure). - Tests: incremental validator unit tests; pipeline tests for corrupted stream -> single well-formed error event + corrupted events withheld, corrupted first event -> exactly one error event, healthy stream unaffected. Trello: https://trello.com/c/S8moAYO6 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code Review — PR #800Overall: strong COE follow-up. Structural fix (validation on the forwarding path, not post-hoc) is the right escalation. The batch/incremental unification prevents test-fixture rules from drifting from runtime-enforcement rules — that's the load-bearing correctness property here. Comments justify their length: the streaming state machine has enough interacting flags ( Correctness
Small suggestions
Test coverageExcellent — three complementary pipeline tests (PR #356 fixture, first-event violation, healthy stream unaffected) plus per-rule incremental-validator tests. The DesignThe tradeoff you called out in the PR body (false positive → one clean failed turn vs. false negative → session brick) is correctly reasoned. Given the enforced rules are exact Anthropic protocol invariants with dedicated tests, log-and-warn on the enforceable rules would just be paying the false-negative cost forever. A future config gate is easy to add if needed; not adding one now is the right call per repo conventions. Nits
Ship it — the correctness properties matter more than the small polish notes above. |
Code reviewExcellent PR overall: the incremental-validator refactor is clean, the batch API is preserved as a one-line wrapper, the enforcement path is minimally invasive, and the test coverage locks in the client-observable contract (corrupted event withheld, one well-formed error event, transaction id included). The COE section is exemplary — it names the class of bug, the architectural gap that caused it, and the structural fix. A few small observations, none blocking: 1.
|
- Do not record transaction.streaming_response_recorded on protocol abort: the partial clean-prefix response was never delivered, and downstream consumers could mistake it for a delivered response. The streaming.protocol_violation event with aborted=true is authoritative. - Reword the client error message so a deterministic policy bug does not read as retry-will-fix; point persistent failures at the administrator with the transaction id. - Note in finalize() docstring that it is only well-defined for streams intended to complete. - Harden emitter.record assertions against args/kwargs call styles and lock in streaming_response_recorded presence/absence per path. - Comment why the aclose getattr guard exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewOverall, this is a well-scoped and well-tested COE follow-up. The refactor of the batch validator into an incremental one shared with the pipeline (single rule set, single class) is the right shape, and the design tradeoff is clearly reasoned in the PR body. I focused on correctness of the mid-stream enforcement path. Strengths
Observations / Minor Suggestions
Test Coverage
VerdictLGTM. The design correctly moves validation from post-hoc to structural (on the forwarding path), which is what prevents recurrence of the PR #356 bug class regardless of individual policy test coverage. The COE analysis is clear about why this needed enforcement rather than just better logging, and the failure mode of a false positive (one clean failed turn) is genuinely better than the failure mode it prevents (bricked session). 🤖 Generated with Claude Code |
|
Post-review note (adversarial verification pass, Jul 7): merges cleanly with #799, but this PR's |
|
Claude-generated merge-queue triage of all open Luthien PRs, requested by Scott (Jul 7, 2026). Advisory only; Scott has not yet acted on these recommendations. Recommendation: request changes (one item), then merge. The fix direction is right: validate-before-forward with a single rule source, converting a bricked-session failure (the #134 / #329 class) into a clean, retryable client error. The item: enforcement is default-on with no configuration flag. The previous behavior was log-and-warn; with this PR a validator false positive aborts a healthy turn, and the heaviest traffic on this proxy is high-volume agent swarms, where any edge case shows up in absolute numbers. A config field that can drop enforcement back to log-and-warn without a redeploy is cheap insurance and makes this safe to land. One human read before merge is warranted since this changes runtime behavior on production traffic. |
Summary
COE follow-up for PR #356, escalating the PR #426 streaming protocol validator from log-and-warn to enforcement. Trello: COE audit: Emit a clean client error instead of bricking the session when the streaming validator detects a corrupted outbound stream
StreamingProtocolValidatorclass (incremental:observe()per event +finalize()at end of stream) instream_protocol_validator.py. The existing batchvalidate_anthropic_event_ordering()is now a thin wrapper over it, so there is exactly one rule set; all 21 pre-existing validator tests pass unchanged._handle_execution_streamingvalidates each outbound event BEFORE forwarding it. On a violation (out-of-order events, the PR fix: safety judge returns 400 on parallel MCP tool use #356 bug class) the pipeline:errorevent ({"type": "error", "error": {"type": "api_error", "message": "Luthien proxy detected a corrupted response stream (protocol violation: <rules>) ... (transaction: <call_id>)"}}) that Anthropic-protocol clients parse as a normal API error,/rewindneeded).streaming.protocol_violationpolicy event withaborted: true; webhook fires withsuccess=False, http_status=500(same accounting as the mid-stream exception path).message_stoplast, all blocks closed, non-empty) stay advisory log-and-warn: they are only decidable after every event has already been forwarded, so they cannot gate forwarding.Definition of done (from the Trello card)
TestProtocolViolationAbortsStream, using the exact PR fix: safety judge returns 400 on parallel MCP tool use #356 corruption shape)Design tradeoff (load-bearing)
Enforcement means a validator false positive would now abort a healthy stream (one failed turn, client retries) instead of just logging. I judged this acceptable because the enforced rules are exact Anthropic protocol invariants with dedicated unit coverage, and the failure mode of a false positive (one clean failed turn) is strictly better than the failure mode it prevents (silently corrupted session). If we want a kill switch, a config field can gate enforcement back to log-and-warn in a follow-up; I kept this PR to the simplest enforcement per repo convention.
RCA / COE
Root cause: PR #426 deliberately wired the protocol validator as log-and-warn, and it validated only after the stream completed, when every event had already been forwarded to the client. A corrupted stream (e.g. content blocks emitted after
message_delta) was therefore delivered intact; the client (Claude Code) reconstructed a malformed assistant message, resent it as history on every subsequent request, and the API returned 400 on each: a bricked session recoverable only via/rewind.Why it wasn't caught: it was, by design review rather than by an incident. PR #426's own description flagged the escalation path ("If we see violations in practice, we can escalate to aborting the stream to prevent session-bricking"), and the 2026-03-25 COE audit of PR #356 turned that into this tracked card. The gap was architectural: post-hoc batch validation cannot protect the client, because detection happens after forwarding. No test asserted what the client receives when the outbound stream is corrupted, only that violations were logged.
Why it won't recur: validation is now structural, on the forwarding path itself: every event passes
StreamingProtocolValidator.observe()before it is yielded to the client, so any future policy bug in this class (PR #134 and PR #329/#356 were both this class) is contained at the proxy boundary regardless of whether the offending policy has test coverage. The batch validator is a wrapper over the same incremental class, so test-fixture rules and runtime-enforcement rules cannot drift apart.TestProtocolViolationAbortsStreamlocks in the client-observable contract: corrupted stream produces exactly one well-formed error event, corrupting events withheld, healthy streams untouched.Test plan
scripts/dev_checks.shclean (ruff format + lint, pyright 0 errors)test_mock_simple_llm_parallel_tools.pyexercises the enforced path end to end)🤖 Generated with Claude Code