A stream that fails after 800 tokens
The 200 was spent on the first token, so the failure arrives in-band or not at all. When a stream can be resumed this is one flag; when it cannot — every LLM API — the flag has nothing to resume from, and the real answer is two small seams of user code.
The problem
You stream a completion to a user. Eight hundred tokens in, it stops. The request did not
fail — 200 OK went out with the first token, and the status line was spent before anything
went wrong. Every later failure has to arrive in-band, as an SSE frame, or as nothing at
all when the socket simply drops.
Three consequences, all awkward:
- Retrying is not neutral. Replaying re-runs the model — you pay for the first 800 tokens and their replacement — and it duplicates content the consumer has already accumulated. The user watches the answer restart.
- "Ended" and "ended early" are the same shape. A complete OpenAI stream is terminated by
[DONE]. A truncated one just stops. Nothing else distinguishes them. - Resumption mostly isn't offered. SSE's
Last-Event-IDneeds the server to putid:on every frame. OpenAI-style completion chunks carry none, so the standard mechanism does not apply to the most common streaming API there is.
The common solutions
| Approach | Where it breaks |
|---|---|
| Retry the whole request | Pays twice, re-runs the model, duplicates content in any accumulator. The answer restarts. |
| Never retry a stream | Safe and common. Turns every transient blip into a visible failure. |
Resume via Last-Event-ID | The correct mechanism — and unavailable on LLM APIs, which emit no id:. |
| Continuation prompt | The pragmatic LLM answer. Costs another call, and the seam shows in the output. |
Buffer everything, emit at [DONE] | Makes truncation detectable and retry safe — and discards the entire point of streaming. |
| Check the sentinel | Necessary in all of the above. Cheap, and routinely forgotten. |
What StitchAPI does
If your stream is resumable, this is one flag and it is correct. A feed that emits id:
on every frame, dropped after 2 of 5 tokens, with sse: { reconnect: true }:
const feed = sse({
url: 'https://api.example.com/events',
sse: { reconnect: true },
});Measured: the consumer saw ABCDE — five deltas, zero duplication — and the reopened
request carried exactly the right header, Last-Event-ID moving (none) → t2, where t2 was
the last id delivered before the drop. Server pacing is honoured too: a retry: 9000 frame
produced 9000 ms gaps, overriding the authored reconnect.delay.
reconnect cannot help an LLM stream — resuming needs id:. With no
id: on any frame there is nothing to replay as Last-Event-ID, so the
engine does not reopen. Measured on an OpenAI-shaped stream with
sse: { reconnect: true }: a completed stream is 1 open, ABCDE once,
one [DONE], and a mid-body drop surfaces its error with the partial
kept (ABC, done(ok: false)) instead of being reopened. An earlier
build replayed the whole completion here — measured by this audit, filed
as #640, fixed in #647 — so the flag on an id-less stream is now a no-op,
not a replay. sse: true is the same flag. What the LLM case actually
needs is below.
For the LLM case the answer is two seams: Surface.execute for connect-only retry, and
the surface's stream hook to require the sentinel and reject in-band error frames.
const TRANSIENT = [429, 502, 503, 504]; // the engine's own default retry.on
// Retry the CONNECT phase only — the one replay that is unambiguously safe.
// `transport` is the injected adapter (a surface with `execute` replaces
// `config.adapter`), and adapters resolve on every status — a 503 is a value,
// not a throw — so the loop tests `res.status`; a catch would never see it.
execute: async (req) => {
let res = await transport(req);
for (let i = 1; i < connectAttempts && TRANSIENT.includes(res.status); i++)
res = await transport(req); // no bytes have flowed yet
return res;
},
// Require the sentinel: truncation is the ABSENCE of a frame, so nothing
// per-frame can catch it.
stream: async function* (res, cfg) {
let sawDone = false;
for await (const frame of sseSurface.stream(res, cfg)) {
if (isErrorFrame(frame)) throw new Error(`provider error frame: ${messageOf(frame)}`);
if (isDone(frame)) { sawDone = true; continue; }
yield frame;
}
if (!sawDone) throw new Error('stream truncated: no [DONE] sentinel');
},Measured across six shapes: a complete answer delivered once; a healed connect retried at
the connect phase only (3 opens, ABCDE once); a mid-body drop keeping ABC; a truncation
caught as stream truncated: no [DONE] sentinel with the partial kept; an in-band error frame
surfaced with the bad frame withheld from the consumer; and a dead server failing after 4
connect attempts rather than resolving empty.
StitchAPI vs the common solution
Unusually for this section, the StitchAPI version is smaller — 62 lines against 83 for the
hand-rolled twin, which has to bring its own SSE parser. Both produce byte-identical results
and identical open counts on all six shapes, so the machinery isn't buying behaviour; it's
buying the spine: one start / delta×N / error / done trace under one traceId, and
auth, headers, throttle and timeout staying configuration instead of growing the
hand-rolled file.
What StitchAPI does not solve here
retrydoes not run on a streaming stitch at all. Measured:retry: { attempts: 4 }against an always-503 server made 4 requests on a buffered stitch and 1 on ansseone, witherror.attempts: 1. The safe replay — the connect phase, before any byte — is the one caseretrycannot cover.retry.attemptsis inert butretry.backoffis live, as the reconnect curve. One name, two fates: on a resumable feed that kept dropping,retry: { attempts: 1 }still produced 4 opens — the cap that binds isreconnect.attempts.- Connect-phase and body-phase policy are not separately addressable.
reconnect.onlyOnDrop,reconnect.requireTokenandretry.phaseare all absent (machine-checked) — the engine now behaves as the first two would have configured, but the phase split itself still has no config spelling. interpretandverdict.flagare dead code on streaming surfaces. A custom surface'sinterpretran zero times —runStreamingcalls onlyclassifyStatus. They typecheck and do nothing.hooks.onErrornever fires for a post-200 stream failure. Measured hook sequence on a mid-body drop:[onRequest, onResponse], while the run failed. Any hook-based error pipeline is blind to this entire scenario.onResponsealso fires before a single frame is parsed —ctx.res.bodyis a liveReadableStream.- Truncation is undetectable by any built-in. A truncated stream and a complete one
produce the same terminal spine —
result, done(ok: true).outputcan't express it either: a schema demanding the sentinel rejects delta 1 instead. - The partial survives only on
.stream()..safe().dataisnull; the thrownStitchErrorhasbody,data,partialandchunksallundefined; and.inspect()— whose whole job is "what did the server actually send?" — returnsdata: null,raw: null,status: 0. The engine is holding the answer one line above thereturnthat discards it.
Default reconnect backoff is roughly 50 ms (expo-jitter off base 100), so a dropped
stream reopens within a tenth of a second unless you author a retry.backoff. And
verdict: { accept: [503] } combined with reconnect resolves a permanently-failing
server successfully with data: [].
See also
Submit, poll, download — the async job triangle
A 202 with a Location header, a status endpoint that reports failure at HTTP 200, and a single-use result URL. Three endpoints and a loop, and you have to pick which guarantee you keep.
The free poll — ETag revalidation and the bodyless 304
A 304 means "use what you have", carries no body, and is not a 2xx. Turning it back into the resource takes one seam — and the cache primitive cannot help.