Post-HTTP-200 SSE Stream Errors: Failure Conditions & Recovery Boundaries
Why simple HTTP status-code tables fail in streaming LLM apps: handling mid-stream SSE error events, proxy idle cuts, and max_tokens JSON truncation.
Node.js 22.13+ SSE Client · Applicable to Claude API, OpenAI API, and Local Ollama / vLLM
Includes local zero-cost SSE guard client to test your parser against mid-stream error events.
- As documented by Claude & OpenAI, once HTTP 200 OK headers are flushed to open an SSE stream, an error occurring mid-generation CANNOT change the HTTP status code.
- Retrying a partially streamed response that already triggered external side effects without an idempotency key causes duplicate writes.
1. Why Checking `if (!res.ok)` Is Not Enough for Streaming LLMs
| Mid-Stream Failure Mode | Wire Frame After HTTP 200 | Naive Client Behavior | Correct Recovery Action |
|---|---|---|---|
| Upstream Overload / CUDA OOM | data: {"type":"error","error":{...}} | Ignores error field; saves half sentence | Throw on event.error; discard partial buffer & retry |
| Reverse Proxy 60s Idle Cut | TCP FIN/RST without [DONE] marker | Loop exits cleanly on done===true | Require explicit [DONE] flag before marking completed |
| max_tokens / num_predict Hit | finish_reason: "length" + [DONE] | Crashes in JSON.parse(output) | Check finish_reason === "stop" before parsing JSON |
| Stalled Socket (Half-Open TCP) | Zero bytes indefinitely | await reader.read() hangs forever | Reset per-chunk watchdog timer & call reader.cancel() |
// 1. Inspect every SSE JSON frame for mid-stream errors after HTTP 200:
if (event.error || event.type === 'error') {
throw new Error(`Mid-stream SSE error: ${JSON.stringify(event.error || event)}`);
}
// 2. Check finish_reason before JSON.parse:
if (event.choices?.[0]?.finish_reason === 'length') {
throw new Error('Truncated by max_tokens before JSON closed');
}
// 3. Enforce per-chunk idle timeout with reader.cancel()2. Distinguishing First-Token Latency (TTFT) from Inter-Chunk Stall
3. Transactional Buffering & Idempotent Retry Boundaries
Frequently Asked Questions & Edge Cases
Q: Does the official OpenAI / Anthropic SDK handle mid-stream SSE error events automatically?
Recent versions of official SDKs throw an `APIError` when an SSE `error` event frame arrives, but they do NOT automatically discard partial text that your `for await (const chunk of stream)` loop already wrote to your UI or database. Always buffer transactional writes until the stream completes with `finish_reason === "stop"`.
Q: Why does Nginx cut off my SSE stream at exactly 60 seconds even when tokens are still generating?
Nginx defaults to `proxy_buffering on` and `proxy_read_timeout 60s`. When buffering is enabled, chunks may sit in Nginx buffers, or a long prefill exceeds 60s between packets. Set `proxy_buffering off; proxy_cache off; proxy_read_timeout 600s;` on the location block.
Resilient LLM SSE Stream Reader (Handles Post-HTTP-200 Mid-Stream Errors)
Detects mid-stream error frames after HTTP 200 OK, idle socket stalls, and premature finish_reason truncation.
// Tinker AI — Resilient SSE Stream Reader with Mid-Stream Error Detection
const API_URL = "http://127.0.0.1:11434/v1/chat/completions";
const IDLE_TIMEOUT_MS = Number("15000");
export async function consumeLlmStream(payload) {
const res = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...payload, stream: true }),
});
if (!res.ok || !res.body) {
throw new Error(`Pre-stream HTTP failure: ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let output = "";
while (true) {
const timer = setTimeout(() => reader.cancel("SSE chunk idle timeout"), IDLE_TIMEOUT_MS);
const { value, done } = await reader.read().finally(() => clearTimeout(timer));
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") return { output, completed: true };
const event = JSON.parse(data);
// Catch mid-stream error frame sent AFTER HTTP 200 OK
if (event.error || event.type === "error") {
throw new Error(`Mid-stream SSE error after HTTP 200: ${ JSON.stringify(event.error || event) }`);
}
const delta = event.choices?.[0]?.delta?.content ?? "";
output += delta;
}
}
return { output, completed: false };
}