The error taxonomy of a stream
Transport dropped, provider errored, tool failed, model refused — four different failures that produce one identical symptom, the text stopping, and the status code was already 200 before any of them happened.
The text stopped. Nothing is on fire, the network panel shows a 200, and the last thing on screen is a sentence that ends without a full stop.
Four completely different things produce that. The transport dropped. The provider errored. A tool failed. The model refused. They need four different responses from your interface, and from the outside they are indistinguishable — which means the distinction has to be made from the events, and your reducer has to have kept them.
The status code was decided before any of this
The structural fact that makes a taxonomy necessary comes from Anthropic — a vendor documenting its own API, and the sentence is worth memorising:
“An error can occur after the API returns a 200 response. In that case, error handling doesn’t follow these standard mechanisms.”
Anthropic’s errors page says that plainly and then moves on, which understates how much of your architecture it invalidates. The status code is chosen when the response starts. Everything interesting happens after. Any error-handling design that branches on response.ok is designed for a world where responses are complete objects, and you left that world when you started streaming.
Four failures, one symptom
| Failure | What arrives on the wire | What the user sees |
|---|---|---|
| Transport dropped | Nothing. The connection ends with no terminal event. | Text stops. |
| Provider errored | An error event, mid-stream, after a 200. | Text stops. |
| Tool failed | A tool-scoped error chunk; the turn may continue. | Text stops. |
| Model refused | A normal, successful completion carrying a refusal. | Text stops. |
Take them one at a time, because each has a different source and a different recovery.
Transport dropped is the one with no evidence. A proxy hit its between-bytes timeout, a QUIC connection went idle, a laptop slept. Nothing surfaces at the application layer because nothing at the application layer went wrong — the bytes simply stopped arriving. The infrastructure lesson covers the mechanisms; what matters here is that this class is identified by absence. You detect it by knowing what a completed stream looks like and noticing you never got one.
That is the practical argument for caring about terminators, and for being precise about which one is yours. Anthropic’s documented sequence ends with message_stop. For OpenAI, the answer differs within the vendor: the Responses API’s documented terminator is response.completed, and this course could not verify that it emits data: [DONE] at all — that page does not mention it. For Chat Completions, the package source states that “an additional chunk will be streamed before the data: [DONE] message”. Say “Chat Completions,” never “OpenAI.”
Provider errored is the class Anthropic’s 200-then-error sentence is about, and its worked example is worth knowing because it is the one you will actually meet: 529 overloaded_error, which the page says “can occur when the API experiences high traffic across all users”. Nothing about your request caused it. Nothing about your request will fix it. This is the one class where a retry is straightforwardly the right move, and it is the reason the other three must not be collapsed into it.
Tool failed has a first-class slot on the wire, and finding it requires reading source rather than documentation. The AI SDK’s shipped chunk schema accepts tool-input-error and tool-output-error, neither of which appears on its public stream-protocol page. If you write a backend that speaks this protocol, the schema file is the authority and the docs page under-documents it. The distinguishing property of this class: the turn is not necessarily over. A tool can fail and the model can carry on, which is why a tool failure deserves its own rendering next to the answer rather than replacing it.
Model refused is the class people leave out, and it is not a failure at all. OpenAI’s Responses API gives it named events: response.refusal.delta, “emitted when there is partial refusal text being streamed,” and response.refusal.done carrying the finalised text, with the content part itself typed as either ResponseOutputText or ResponseOutputRefusal.
{ "type": "response.refusal.delta", "item_id": "...", "output_index": 0, "content_index": 0, "delta": "I can't help with that because", "sequence_number": 12 }
{ "type": "response.refusal.done", "item_id": "...", "output_index": 0, "content_index": 0, "refusal": "I can't help with that because ...", "sequence_number": 13 }The model completed normally and declined. Retrying is the wrong move, an error state is a lie, and a silent stop is the worst of the three because the user reads a truncated answer as a complete one.
Do not generalise the refusal event
Those named events are OpenAI’s. This course’s research pass looked for an equivalent in Anthropic’s own streaming and errors documentation and did not find one: Anthropic does not name a distinct refusal event type, and a declining response arrives as ordinary text content in the ordinary sequence.
So the refusal class is real everywhere and machine-detectable in only some places. If you switch providers, the taxonomy survives and the detection does not. That asymmetry is exactly why the agent-stream lesson told you to own your event union and adapt at the edge: your union has a refusal arm because your product needs one, and the adapter fills it from whatever that provider gives you, up to and including nothing.
Make the classes visible in the type
The taxonomy is only worth anything if it survives into code. One collapsed arm and it is a diagram.
type TurnEnding =
| { kind: 'completed' } // terminal event seen
| { kind: 'refused'; text: string } // model declined, normally
| { kind: 'provider-error'; status?: number; code?: string }
| { kind: 'tool-error'; callId: string; message: string } // turn may continue
| { kind: 'transport-dropped'; lastEventAt: number } // no terminal eventTwo properties make this the right shape. Each arm has a different recovery: retry the provider error, do not retry the refusal, offer a resume for the transport drop, keep rendering the answer around the tool error. And transport-dropped is inferred rather than received, so it needs a timestamp and something watching — you cannot handle an event that never arrives.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Your UI shows one “Something went wrong, retry?” state for every failure. Name the specific harm each of the four classes takes from that, in order.
Check your answer
Transport dropped: retry is close to right, but it restarts a run that is probably still generating on the server, so you pay twice and the user waits twice. The correct offer is to reattach, which the two-tabs-one-run lesson builds.
Provider errored: this is the one case the generic state fits, which is exactly why it survives review and the other three do not.
Tool failed: you have thrown away a partially correct answer. The model may have kept going; the turn may have finished usefully with one lookup missing. Replacing the answer with an error box destroys work that was fine.
Model refused: you have told the user the system broke when the system worked. They retry, get refused again, and lose trust in the product rather than learning what it will not do.
Hands on
Produce all four failures on purpose and tell them apart
Done when: The flight chatbot renders four visibly distinct endings, each triggered deliberately at least once, and ARTIFACT.md records for each class: how you produced it, what arrived on the wire, and what the UI did.
- Add the
TurnEndingarms to the event union you wrote in the agent-stream lesson, with an exhaustive switch and anevercheck, so a missing arm fails the type check rather than the screen. - Force a transport drop. A proxy in front of the app with a short between-bytes timeout and a stalled tool call is the cleanest way, and you already built that proxy. Confirm your client notices the missing terminal event rather than waiting forever.
- Force a provider error mid-stream. If you cannot make the provider emit one on demand, inject the error event into your own stream at the adapter boundary — the point is that the UI handles an error arriving after a 200, which is the thing your old code path could not represent.
- Force a tool failure and confirm the answer around it survives. The bar: the failed lookup is visibly marked, the rest of the turn keeps rendering, and nothing replaces the answer with an error box.
- Force a refusal by asking for something the model will decline. Record what actually arrives on your provider — a named refusal event or ordinary text — and design the rendering for what you got, not for what you wish you got. If the refusal is undetectable on your provider, write that in the file as a known limitation with its consequence.
- Screenshot the four endings side by side and bring them into the chat. I will look for two that are the same picture, because that is where the taxonomy quietly collapsed back into one state.
What this does not cover
Retry policy is deliberately thin here. Deciding what to do with the partial answer already on screen when you retry — and why the retry that looks correct in a demo is the one that silently duplicates half an answer — belongs to the retrying-mid-stream lesson in the control module, and the interaction for correcting an answer already rendered belongs to the lesson on the partially wrong answer beside it.
There is nothing here about measurement. Error rates, failure budgets, alert thresholds and the cost of re-rendering after a failed turn belong to Front-end performance under streaming load. This lesson is about telling four things apart, not about counting them.
And one ending is missing from the taxonomy on purpose, because it is not a failure of the stream at all: the user closed the tab. Nothing errored, nothing dropped, nothing refused, and the generation is still running. What that costs, and who decides, is the last lesson.
Read this next — primary source
Responses streaming events — API referenceOpenAI — vendor documenting its own product. Verified 2026-09-05
This lesson takes its refusal events from it, and the full event list is the reason to read the page rather than this summary. Every semantic event the Responses API can emit is enumerated there with its exact fields, which is what lets you build a taxonomy from what actually arrives instead of from what you assume arrived. Read it beside your own provider’s equivalent page and note the differences: the point of this lesson is that the four failure classes are real everywhere, while the events that distinguish them are not standardised across vendors.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.