Skip to content

Responses stream: emit response.failed on abnormal termination - #1639

Open
andreasfoo wants to merge 1 commit into
mainfrom
claude/gracious-gates-ckvsis
Open

andreasfoo wants to merge 1 commit into
mainfrom
claude/gracious-gates-ckvsis

Conversation

@andreasfoo

Copy link
Copy Markdown
Collaborator

Summary

Responses streams that terminate abnormally (upstream truncation, read errors, in-band errors) now emit a response.failed event before the legacy error frame. This ensures Codex and other strict consumers can diagnose the failure reason instead of reporting a generic "stream closed before response.completed".

Key Changes

  • Responses stream failure signaling: HandleOpenAIResponsesStream now calls SendResponsesStreamFailure on abnormal termination, emitting response.failed with error code and message, followed by the legacy error frame for backward compatibility. The response ID is carried through if already announced to the client.

  • Error detail recovery: describeResponsesStreamError restores diagnostic information that the OpenAI SDK drops when aborting on in-band error events (e.g., "error": null), re-attaching the raw event to the error message.

  • Input validation for Responses: checkResponsesInputSurvived guards against silent input array loss during SDK deserialization. When an item type the pinned SDK doesn't model appears, the entire array decodes to nothing without error — this now fails the request with a clear message naming the offending item, preventing silent conversation loss on retry loops.

  • Usage backfill safety: The reasoning_tokens backfill now checks IsObject() instead of Exists() to avoid synthesizing incomplete usage objects from null values in response.created / response.in_progress events.

Minor

  • Added comprehensive test coverage for Responses stream failure paths, input validation, and error message recovery.
  • Updated .design/stream-converter-pipeline.md with the Responses stream termination protocol and rationale.

Notes

The response.failed event structure follows the protocol's own failure format: response.error.{code,message} with codes upstream_truncated (no terminal event) and stream_failed (read/decode error). Codex's SSE reader has a match arm for response.failed but not for bare error frames, making this change essential for error visibility in that consumer.

https://claude.ai/code/session_01ShwPKYgqy6nsZfXT5vwBoc

…see why

Codex reports every gateway-side stream failure as the same opaque line —
"stream disconnected before completion: stream closed before response.completed"
— and retrying never helps, because the reason we do emit never reaches it.

The gateway already detects both failure shapes (upstream clean EOF with no
terminal event, upstream read/decode error) and emits an `event: error` frame.
But codex-rs's SSE reader (codex-api/src/sse/responses.rs, process_responses_event)
has no match arm for "error": the frame falls into the catch-all and is dropped,
the stream then ends normally, and the client falls back to its generic message
with the actual cause discarded. `response.failed` IS handled there — Codex reads
response.error.{code,message}, classifies it (context window / quota / rate limit
/ retryable) and surfaces the message.

So terminate such streams the way the protocol does: emit `response.failed`
first, then keep the legacy `error` frame for clients that key off it. Codes
are unchanged (upstream_truncated / stream_failed), and the response id already
announced on the wire is carried over so the failure attaches to the response
the client was streaming.

Also fixed, both found while tracing that path:

- `"usage": null` on response.created / response.in_progress was rewritten into
  `{"output_tokens_details":{"reasoning_tokens":0}}` by the reasoning-token
  backfill, because gjson reports an explicit null as existing. That handed
  clients a usage object missing every required field. Require an object.

- openai-go aborts a stream on any event carrying a top-level "error" key —
  including `"error": null` — with the gjson string form of that value as the
  message, i.e. empty. describeResponsesStreamError re-attaches the raw event so
  the log and the client get something to act on instead of a bare
  "received error while streaming: ".

- A Responses input item whose `type` the pinned SDK does not model makes the
  whole input union decode to nothing, without an error: the request would go
  upstream carrying only instructions and tools, with the entire conversation
  silently gone — and agentic clients replay their history every turn, so it
  repeats for the rest of the session. checkResponsesInputSurvived rejects that
  and names the offending item index and type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShwPKYgqy6nsZfXT5vwBoc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants