Skip to content

fix: read a streamed reply by its first bytes, not only its content-type - #165

Merged
CMGS merged 2 commits into
mainfrom
fix/parasail-sse-164
Oct 23, 2026
Merged

CMGS merged 2 commits into
mainfrom
fix/parasail-sse-164

Conversation

@CMGS

@CMGS CMGS commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #164.

Cause

HttpTransport::send chose the stream path from content-type alone. Parasail answers stream: true with content-type: application/json and a standard SSE body:

HTTP/2 200
content-type: application/json

data: {"id":"chatcmpl-…","object":"chat.completion.chunk",…}

The transport read the whole stream as one body, parse_json failed on data: as an internal 500, and that 500 counts as an account fault. After failure_threshold such calls, the account cooled down for every model on it, including non-streamed calls that work.

Fix

For a stream request without an SSE content-type, the transport reads the first chunk:

  • A 2xx body that opens with data: is relayed as SSE, with that chunk put back in front.
  • A JSON answer or an error status is buffered as before, within the same timeout.

Non-stream requests and SSE-labelled responses take the code path they took before. The health rule stays as it is: a 200 the gateway cannot parse is still an account fault. With the stream read correctly, Parasail no longer trips it.

Other providers

Only the OpenAI chat, Anthropic, Responses, Gemini (alt=sse), DashScope native and Bedrock engines send stream requests. Every one of those vendors labels its stream text/event-stream or application/vnd.amazon.eventstream, so none of them reaches the new branch. A vendor that answers a stream request with JSON (for example a 200 error envelope) still gets a JSON body. The only difference on that path is the read-error message, which is now upstream stream failed: …. The status (502) and error code are unchanged.

Hot path

Zero for correctly labelled streams and for non-stream calls. Only a mislabelled stream pays one extra boxed stream adapter. A JSON answer to a stream request is copied once into the buffer.

A/B through a real HTTP hop (front gateway → mock-mode gateway as upstream), main vs this branch, interleaved with the order swapped, 10 s each:

shape main PR
stream c=1 4,873 / 4,973 rps, p50 0.20 ms 4,927 / 4,918 rps, p50 0.20 ms
stream c=64 30,688 / 30,597 rps 30,539 / 30,912 rps
non-stream c=1 8,205 / 8,241 rps 8,253 / 8,244 rps
non-stream c=64 52,830 / 45,901 rps 53,655 / 50,927 rps

Evidence

  • Live Parasail through a local gateway, two models (a Qwen3-VL 8B instruct model and a UI-grounding model), 3 streamed calls each, then 1 non-streamed call:
    • main: every stream returns parse openai response. After the third, both models return 503 no healthy upstream account for streamed and non-streamed calls alike. This reproduces both symptoms in the issue.
    • This branch: every call returns 200. The streams relay 10–15 frames incrementally and the usage frame arrives (10 prompt / 16 completion tokens).
  • a_stream_request_body_is_read_by_its_first_bytes fails on main (the SSE case comes back as JSON) and passes here. It also covers a JSON answer and an SSE-shaped error status.
  • cargo fmt --check, cargo clippy --workspace --all-targets -D warnings: clean.
  • cargo test --workspace with real Postgres 17 and Redis 7: 764 passed, 0 failed, 4 ignored. The Postgres- and Redis-backed suites ran: the test databases were created and Redis keys were written.

Second commit: TLS advisories

cargo deny started failing on two RustSec advisories published after main's last CI run, so main fails the same check today:

  • RUSTSEC-2026-0382: rustls-webpki may accept distrusted intermediates.
  • RUSTSEC-2026-0381: webpki-roots bundles the distrusted emSign roots.

Cargo.lock moves rustls-webpki 0.103.15 → 0.103.16 and webpki-roots 1.0.8 → 1.0.10. The 0.26.11 shim follows the 1.x line.

Checks after the bump:

  • cargo deny check: advisories, bans, licenses and sources all ok.
  • clippy is clean, and cargo test --workspace gives 764 passed, 0 failed, 4 ignored.
  • A live HTTPS call to Parasail succeeds both streamed and non-streamed.

Known limitation

The check reads exactly one chunk, and bytes_stream() does not guarantee where chunks end. A mislabelled stream whose first chunk ends before data: is complete, or that opens with a comment line (: keep-alive), a bare newline or event:, is still buffered as JSON and fails the way it does on main. Correctly labelled streams and non-stream calls never reach this check.

No current vendor has shown this: every live Parasail stream delivered a complete data: {...} in its first chunk over HTTP/2. If a mislabelled vendor that does show it turns up, widen the prefix check and read across chunks within the same deadline.

Size

Production +20 lines, comments +0, tests +45. docs/providers.md notes the rule and lists Parasail among the OpenAI-compatible vendors.

CMGS added 2 commits October 23, 2026 10:19
Parasail labels its SSE streams application/json, so every streamed call
was buffered whole, failed JSON parsing as an internal 500, and three of
them cooled the account for every caller of every model on it. A stream
request's 2xx body that opens with `data:` is now relayed as SSE; JSON
answers and error statuses stay buffered as before.
@CMGS
CMGS merged commit a54c1a3 into main Oct 23, 2026
2 checks passed
@CMGS
CMGS deleted the fix/parasail-sse-164 branch October 23, 2026 03:09
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.

openai provider: Parasail streaming fails to parse, and the parse error marks the account unhealthy

1 participant