[Bug]: Responses API silently drops malformed JSON chunks and hangs streams (Patch Ready)

Open 💬 3 comments Opened Jul 5, 2026 by DhruvTilva
### Description
There is a silent failure mode in the Responses API streaming handlers. When the backend streams a malformed or truncated JSON response chunk (e.g., due to a network blip or an unexpected upstream model output), the client silently swallows the error, drops the event, and continues the loop until the stream hangs and hits the `idle_timeout`.

### Root Cause Analysis
In both the SSE and WebSocket response stream loops, `serde_json::from_str` failures are caught, logged at the `debug!` level, and explicitly bypassed via a `continue` statement. 

**SSE (`src/sse/responses.rs`):**
```rust
let event: ResponsesStreamEvent = match serde_json::from_str(&sse.data) {
    Ok(event) => event,
    Err(e) => {
        debug!("Failed to parse SSE event: {e}, data: {}", &sse.data);
        continue; // <-- Silently drops the malformed chunk
    }
};

WebSocket (src/endpoint/responses_websocket.rs):

let event = match serde_json::from_str::<ResponsesStreamEvent>(&text) {
    Ok(event) => event,
    Err(err) => {
        debug!("failed to parse websocket event: {err}, data: {text}");
        continue; // <-- Silently drops the malformed chunk
    }
};

Impact

  1. Silent Data Loss: Malformed response.output_text.delta chunks are dropped, resulting in missing tokens in the client UI without any visible error.
  2. Hanging Streams: If the terminal response.completed event is truncated or malformed, the loop never receives the completion signal. The stream hangs indefinitely until it hits the idle_timeout, yielding a misleading "stream closed before response.completed" error instead of surfacing the underlying parse failure.

Proposed Solution (Patch Ready)

We should enforce fail-fast behavior. When serde_json::from_str fails, the stream should immediately break and bubble up an ApiError::Stream containing the parse error.

I have already written and tested a patch that:

  1. Replaces the continue statements with proper error returns in both responses.rs and responses_websocket.rs.
  2. Adds an E2E test (error_on_malformed_json_event in sse_end_to_end.rs) that mocks a truncated response.completed event and asserts that the stream correctly halts and bubbles up the JSON parse error instead of hanging.

I have the branch pushed on my fork. I would be happy to submit the Pull Request if a maintainer is willing to invite/review it!

View original on GitHub ↗

3 Comments

trash-80 · 1 month ago

I think I hit a variant of this that's worth linking here.

Symptom: Using an MCP server (an aggregator server with ~35 tools) via Codex CLI, I get repeated stream disconnected before completion: stream closed before response.completed errors specifically during turns that involve MCP tool calls.

Bisection: I bisected this to the exact release boundary:

v0.120.0 — works correctly
v0.121.0 — regression starts (first bad version)
That version boundary corresponds to commit d65ed92a5e440972626965d0af9a6345179783bc. The most likely culprit in that release is #17404 ("register all mcp tools with namespace"), which introduced a new ToolSpec::Namespace wire-format variant and changed how function-call events are represented to/from the Responses API (ev_function_call_with_namespace, ToolName::namespaced(...), etc.).

Why I think this is the same bug as this issue: Your root cause analysis shows that malformed/unexpected serde_json::from_str payloads in the SSE/WebSocket response loops are silently dropped via continue instead of erroring, and if the dropped event is (or gates) the terminal response.completed event, the stream hangs until idle_timeout — producing exactly this "stream closed before response.completed" message.

My theory: the #17404 namespace refactor changed the shape of function-call/tool-related events on the wire. If that produces an event shape your deserializer doesn't expect in some edge case (e.g., during MCP tool-call resolution), it would get silently swallowed by the bug described here instead of surfacing a clear parse error — which would explain why this only reproduces for me when MCP tools are in play, and only starting at 0.121.0.

I ruled out my MCP server as the cause: I upgraded its underlying FastMCP dependency from 3.2.4 to 3.4.4 (fully tested, no code changes needed) and the regression persists identically on 0.121.0, while 0.120.0 continues to work fine against the same upgraded server. So this looks like it's entirely on the Codex client side.

Happy to provide more repro details/logs if useful. Given your patch already fails fast on parse errors instead of dropping them, it seems like it would surface (and likely fix) this case too — is there anything blocking it from being turned into a PR?

collinarnett · 1 month ago

Also being affected by this issue.

DhruvTilva · 1 month ago

Summary of where this stands

The root cause and fix are confirmed, and there's now a second independent report of the same underlying bug. Recapping so it's easy to action:

What's broken: In both src/sse/responses.rs and src/endpoint/responses_websocket.rs, a serde_json::from_str failure on a malformed/truncated chunk is logged via debug! and then swallowed with continue. This causes two failure modes:

  1. Silent token loss when a response.output_text.delta chunk fails to parse.
  2. Indefinite hangs when the terminal response.completed event is the one that's malformed — the loop never sees completion and the stream idles out, surfacing a misleading "stream closed before response.completed" error instead of the real parse failure.

Corroborating report: @trash-80 hit what looks like the same bug via MCP tool-call turns through Codex CLI, bisected to v0.121.0 as the first bad version, and traced it to the namespace wire-format change in #17404. Their theory is that the new ToolSpec::Namespace event shape produces a parse edge case during MCP tool-call resolution that gets silently dropped by this same continue path — which would explain why it only reproduces with MCP tools in play.

The fix (already written and tested on my fork):

  • Replaces continue with fail-fast return Err(ApiError::Stream(...)) in both the SSE and WebSocket loops.
  • Adds an E2E test (error_on_malformed_json_event in sse_end_to_end.rs) that mocks a truncated response.completed event and asserts the stream halts and surfaces the parse error instead of hanging.
  • Branch: fix-responses-api-silent-failures on my fork, 3 files changed, ready to open against main.

Since external PRs here are invite-only per the contributing guide, I wanted to consolidate the analysis in one place rather than leave it split across comments. Happy to open the PR the moment someone gives the go-ahead — @aibrahim-oai / @pakrym-oai, given you both touched sse/responses.rs recently, would one of you be able to take a look and invite a PR if this looks right? Also glad to dig into whether the #17404 namespace change is the specific trigger for the MCP variant if that's useful context before merging.

Thanks for the patience — let me know if you'd like anything else from my side (logs, a narrower repro, etc.).