A clean socket close is not a finished answer.
A streamed response has three possible endings: completed, truncated, aborted. Most clients collapse all three into one boolean called done. That boolean is exactly where half-answers get saved as final.
This is a debugging retrospective you can replay. I am reconstructing the failure rather than quoting a specific incident, so every step below runs on your own machine.
The symptom: HTTP 200, zero errors, half a sentence
A ticket says the assistant stops mid-sentence. The dashboard disagrees. Error rate is zero, p95 latency is flat, and every response logged status 200.
So which signal lied? None of them. They measured transport health, not answer completeness.
That gap is the whole bug. Your metrics watch the socket, your users watch the sentence.
Step 1 — Record the raw bytes before you theorize
Never debug a stream through a pretty-printer. Save the wire format first, then read it.
mkdir -p fixtures curl -sN 'https://your-provider/v1/chat/stream' \ -H 'Content-Type: application/json' \ -d '{"stream":true,"messages":[{"role":"user","content":"explain the fix"}]}' \ > fixtures/case-001.sse wc -l fixtures/case-001.sse tail -3 fixtures/case-001.sse Two things in that tail matter more than everything else. Look for the sentinel data: [DONE], and look at the last finish_reason value.
Ask yourself one question: did the provider tell you why it stopped?
Step 2 — Name the terminal states out loud
Write the states down before touching code. Three states, three different causes, three different handling rules.
| Seen on the wire | Real meaning | Safe to persist? |
|---|---|---|
finish_reason: "stop" then [DONE] | completed | yes |
finish_reason: "length" then [DONE] | truncated by max tokens | no |
socket closed, no [DONE] | aborted by proxy or idle timeout | no |
| HTTP 5xx or reset mid-stream | errored | no |
Notice what all four rows share. The transport succeeded in three of them. Only one row deserves the word complete.
So why does almost every client return a string instead of a status? Because a string is easier to type, and easier to type is easier to get wrong.
Step 3 — Build a provider that misbehaves on demand
You cannot fix what you cannot trigger. So write a stub provider with three switchable endings.
// stub-provider.mjs — a provider that fails on purpose import express from 'express'; const app = express(); const words = ['The', 'fix', 'is', 'to', 'assert', 'on', 'the', 'sentinel']; const frame = (obj) => `data: ${JSON.stringify(obj)}\n\n`; const delta = (w) => frame({ choices: [{ delta: { content: w + ' ' }, finish_reason: null }] }); const end = (reason) => frame({ choices: [{ delta: {}, finish_reason: reason }] }); app.get('/v1/chat/stream', (req, res) => { const mode = req.query.mode ?? 'ok'; res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); if (mode === 'truncate-length') { words.slice(0, 4).forEach((w) => res.write(delta(w))); res.write(end('length')); // provider says why it stopped res.write('data: [DONE]\n\n'); return res.end(); } if (mode === 'cut-socket') { words.slice(0, 4).forEach((w) => res.write(delta(w))); return req.socket.destroy(); // proxy timeout: no sentinel at all } words.forEach((w) => res.write(delta(w))); res.write(end('stop')); res.write('data: [DONE]\n\n'); res.end(); }); app.listen(8787, () => console.log('stub provider on :8787')); npm i express node stub-provider.mjs & curl -sN 'http://localhost:8787/v1/chat/stream?mode=truncate-length' You now have reproducibility without a vendor account. That single step kills most stream bugs.
Step 4 — Fix the client, including the bug hiding behind the bug
Here is the shape of the broken client I keep meeting in code review.
// buggy-client.mjs — every ending becomes success export async function readStreamBuggy(url) { const res = await fetch(url); const decoder = new TextDecoder(); let text = ''; for await (const part of res.body) { const s = decoder.decode(part, { stream: true }); for (const line of s.split('\n')) { // bug C: frames split across chunks if (!line.startsWith('data:')) continue; const payload = line.slice(5).trim(); if (payload === '[DONE]') return text; // bug A: sentinel never inspected text += JSON.parse(payload).choices?.[0]?.delta?.content ?? ''; } } return text; // bug B: loop end means success } Three bugs, one symptom. Bug A returns before reading finish_reason. Bug B treats an aborted socket as a finished answer. Bug C splits JSON across network chunk boundaries and silently drops fragments.
Note that bug B is invisible in tests that only use happy-path fixtures. That is why step 3 exists.
The fixed version returns a state, not just a string.
// fixed-client.mjs — terminal state is explicit export async function readStream(url, { onDelta } = {}) { const res = await fetch(url); if (!res.ok) throw new Error(`http_${res.status}`); const decoder = new TextDecoder(); let buffer = ''; let text = ''; let finishReason = null; let sawSentinel = false; const handleLine = (line) => { if (!line.startsWith('data:')) return; const payload = line.slice(5).trim(); if (payload === '[DONE]') { sawSentinel = true; return; } const choice = JSON.parse(payload).choices?.[0]; if (!choice) return; if (choice.delta?.content) { text += choice.delta.content; onDelta?.(choice.delta.content); } if (choice.finish_reason) finishReason = choice.finish_reason; }; try { for await (const part of res.body) { buffer += decoder.decode(part, { stream: true }); let idx; while ((idx = buffer.indexOf('\n')) !== -1) { handleLine(buffer.slice(0, idx).replace(/\r$/, '')); buffer = buffer.slice(idx + 1); } } } catch (err) { return { status: 'aborted', text, finishReason, error: err.message }; } if (buffer.length) handleLine(buffer); if (!sawSentinel) return { status: 'aborted', text, finishReason }; if (finishReason !== 'stop') return { status: 'truncated', text, finishReason }; return { status: 'complete', text, finishReason }; } The rule that follows is blunt. Persist text only when status === 'complete'. Everything else gets stored as a partial draft with a visible reason.
Step 5 — Freeze the failure as a test
A fix without a test is a rumor. Three cases cover the whole surface.
// stream.test.mjs import test from 'node:test'; import assert from 'node:assert/strict'; import { readStream } from './fixed-client.mjs'; const BASE = process.env.PROVIDER_URL ?? 'http://localhost:8787/v1/chat/stream'; test('clean stream completes', async () => { const r = await readStream(`${BASE}?mode=ok`); assert.equal(r.status, 'complete'); assert.equal(r.finishReason, 'stop'); }); test('max_tokens is not success', async () => { const r = await readStream(`${BASE}?mode=truncate-length`); assert.equal(r.status, 'truncated'); assert.equal(r.finishReason, 'length'); }); test('socket close without sentinel is aborted', async () => { const r = await readStream(`${BASE}?mode=cut-socket`); assert.equal(r.status, 'aborted'); }); node --test stream.test.mjs Run that in CI against the stub, not against the vendor. Vendor tests are slow, flaky, and expensive to keep green.
Step 6 — Make truncation visible in production
Count endings, not just errors. Emit one counter per terminal state and alert on the ratio.
const counters = { complete: 0, truncated: 0, aborted: 0 }; const record = (status) => { counters[status] += 1; }; const truncatedRatio = () => counters.truncated / Math.max(1, counters.complete + counters.truncated); If truncated climbs while complete is flat, your prompts grew and your max tokens did not. That is a product bug wearing a metrics costume.
Where a free tier and a free server actually help
Operator-supplied statement: MonkeyCode offers free model access and a free server option. I have not benchmarked either, and allowances change, so check the current numbers on the project page before planning around them.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Used honestly, the two pieces cover different steps of this workflow.
- Replay fixtures cost nothing, so the CI suite stays on recorded bytes.
- Live sampling needs many cheap calls while you learn which
finish_reasonvalues a real provider emits. - The free server hosts the stub provider plus a replayer, so a reviewer reproduces your bug in one command.
What it does not give you is a latency guarantee. Never infer production throughput from a shared free environment, and never push load tests at it.
Decision table: fixtures, free tier, or paid path
| Need | Use recorded fixtures | Use a free tier | Use paid infra |
|---|---|---|---|
| Regression tests in CI | yes | no | no |
| Discovering real terminal states | no | yes | yes |
| Latency and cost modeling | no | no | yes |
| Debugging with customer data | sanitize first | sanitize first | per policy |
Who should not use this approach
- You need contractual uptime or latency numbers. Free infrastructure is a sandbox, not an SLA.
- Your prompts carry regulated data. Sanitize before any third-party call, or record nothing.
- Your provider does not use SSE. The sentinel still applies, but the parsing layer does not.
- Nobody owns the observability change. A counter nobody reads changes nothing.
The takeaway I keep relearning
One boolean cannot describe three endings. Model the terminal state explicitly, assert on the protocol sentinel instead of the transport event, and record the bytes before you record your theory.
If you want a cheap place to sample what a real provider actually emits, the free model tier is a reasonable sandbox. Run the harness, print every finish_reason you see, and tell me which one surprised you.