Skip to content
AI Solutions13 min read

A 200 Does Not Mean It Worked: LLM Streaming Failures in Production

A streaming response is a sequence of events, not a request that either succeeds or fails. It can open cleanly, deliver half an answer, and then send an error — and the four failure modes that follow from that all produce code which looks correct and is not.

  • Streaming
  • Anthropic API
  • Server-Sent Events
  • Production AI
  • AI Architecture
A conveyor belt carrying parts in sequence, representing a stream of discrete events that can stop partway

Key takeaways

  • A stream that opens with a 200 can still fail. The API may send an error event mid-stream — an overloaded_error, for instance, which outside streaming would have been an HTTP 529 — so success is proven by receiving message_stop, not by the response status.
  • Token counts in message_delta are cumulative, not incremental. Summing them across events inflates your recorded spend, and the error compounds with the number of deltas rather than staying constant.
  • Tool inputs arrive as partial JSON strings across multiple deltas and cannot be parsed until the block closes. Accumulate by the block index, because multiple content blocks appear in one response and their events interleave.
  • Resume semantics changed by model generation. Claude 4.5 and earlier continue by placing the partial text in an assistant message; 4.6 and later use a user message instructing the model to continue from where it left off.
  • Tool use and extended thinking blocks cannot be partially recovered. Only the most recent text block can be resumed, so an interruption during tool input means re-running that step rather than patching it.
Level
advanced
Time to implement
Half a day to audit an existing stream handler against all four
Written for
Engineers

Non-streaming requests have a comfortable property: one call, one outcome. It worked or it did not, and the status code says which. Streaming discards that, and most stream-handling code is written as though it did not.

A streaming response is a sequence of discrete events arriving over an open connection. It can begin correctly, deliver two thirds of an answer, and then send an error. The HTTP status was decided long before that happened and will never be revised.

The event flow you are actually consuming

Worth stating precisely, because three of the four failure modes below are consequences of its shape. The documented flow is: a message_start carrying a Message with empty content; then a series of content blocks, each opening with content_block_start, emitting one or more content_block_delta events, and closing with content_block_stop; then one or more message_delta events carrying top-level changes; then message_stop.

text
message_start                      Message, content: []
  content_block_start   index 0    type: text
  content_block_delta   index 0    text_delta        x N
  content_block_stop    index 0
  content_block_start   index 1    type: tool_use
  content_block_delta   index 1    input_json_delta  x N   <- partial JSON
  content_block_stop    index 1
message_delta                      stop_reason, CUMULATIVE usage
message_stop                       <- the only proof of completion

  ping events may appear anywhere
  error events may appear anywhere

Two properties of that structure matter more than they look. Every content block carries an index corresponding to its position in the final content array, and a single response routinely contains several blocks — text, then a tool call, then more text. Their events are distinguished only by that index.

Failure one: the error that arrives after the 200

The API can send an error inside the event stream. The documented example is an overloaded_error during periods of high usage — the same condition that would have produced an HTTP 529 on a non-streaming request, arriving instead as an event on a connection that already returned 200. The full set of error types is worth reading, because each one implies a different retry decision and a stream can deliver any of them.

text
event: error
data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}

Handlers that only inspect the status, or that wrap the whole call in a try/except expecting an exception, treat the partial output as the complete answer. Downstream, that becomes a truncated summary stored as final, or a half-written record committed as though it were whole. Nothing raises.

Assert on message_stop rather than on the loop ending

The correction is small and structural: track completion explicitly, and treat its absence as a failure regardless of how the loop ended.

python
class StreamIncomplete(RuntimeError):
    """The stream ended without message_stop."""


def consume(stream) -> dict:
    blocks: dict[int, list[str]] = {}
    completed = False
    stop_reason = None

    for event in stream:
        etype = event.get("type")

        if etype == "error":
            # A 200 was already returned. This is the real outcome.
            raise StreamIncomplete(event["error"]["type"])

        elif etype == "content_block_delta":
            blocks.setdefault(event["index"], []).append(delta_text(event))

        elif etype == "message_delta":
            stop_reason = event["delta"].get("stop_reason") or stop_reason

        elif etype == "message_stop":
            completed = True

        # Unknown event types are ignored deliberately — the API adds new
        # ones, and a handler that throws on them breaks on the next release.

    if not completed:
        raise StreamIncomplete("stream ended without message_stop")

    return {"blocks": blocks, "stop_reason": stop_reason}

Failure two: usage counts are cumulative

This one is called out in a warning box in the documentation and is still routinely missed, because the mistake produces plausible numbers rather than obviously broken ones. Token counts in the usage field of message_delta are cumulative, not per-event.

Summing them across events therefore does not give you the total. It gives you a triangular number of the total, and the size of the error scales with how many message_delta events arrived — so short responses look nearly right and long ones look wildly expensive.

python
# Wrong: usage is cumulative, so this sums a running total.
total = 0
for event in stream:
    if event["type"] == "message_delta":
        total += event["usage"]["output_tokens"]   # inflates with each delta

# Right: take the last value, which already includes everything before it.
latest = 0
for event in stream:
    if event["type"] == "message_delta":
        latest = event["usage"]["output_tokens"]

The consequence is not cosmetic if those numbers drive anything. Cost dashboards over-report, per-tenant billing over-charges, and a spend ceiling built on the summed figure trips early and kills healthy runs — which is the opposite of the failure described in why agent bills explode, arriving from the same field read the wrong way.

Failure three: tool inputs arrive as unparseable fragments

Streaming and tool use do not compose as neatly as either does alone. A tool call's input field arrives through input_json_delta events carrying partial JSON strings, while the final tool_use.input is always an object. Between those two states the payload is not valid JSON and cannot be parsed.

text
content_block_delta  index 1  partial_json: ""
content_block_delta  index 1  partial_json: "{\"location\":"
content_block_delta  index 1  partial_json: " \"San"
content_block_delta  index 1  partial_json: " Francisc"
content_block_delta  index 1  partial_json: "o,"
content_block_delta  index 1  partial_json: " CA\"}"
content_block_stop   index 1          <- only now is it parseable

Accumulate by index, not into one buffer

Accumulate the fragments and parse once the block closes. Accumulate them keyed by index rather than into a single buffer, because a response contains several content blocks and nothing but the index distinguishes their events — concatenating everything produces a string that is neither tool call.

python
pending: dict[int, dict] = {}
calls: list[dict] = []

for event in stream:
    etype = event["type"]

    if etype == "content_block_start":
        block = event["content_block"]
        if block["type"] == "tool_use":
            # Key on index: several blocks are open across one response.
            pending[event["index"]] = {
                "id": block["id"], "name": block["name"], "buf": [],
            }

    elif etype == "content_block_delta":
        entry = pending.get(event["index"])
        if entry and event["delta"]["type"] == "input_json_delta":
            entry["buf"].append(event["delta"]["partial_json"])

    elif etype == "content_block_stop":
        entry = pending.pop(event["index"], None)
        if entry:
            # Complete only now. Parsing earlier fails on partial JSON.
            entry["input"] = json.loads("".join(entry["buf"]))
            calls.append(entry)

Failure four: resume semantics differ by model generation

When a stream is interrupted, the documented recovery is to capture what arrived and issue a continuation request rather than repeating the whole generation. The mechanism for that continuation is not the same across model versions, which makes it a genuine migration hazard.

Model generationHow to continue
Claude 4.5 and earlierPlace the captured partial response as the beginning of a new assistant message and let generation continue it.
Claude 4.6 and laterAdd a user message containing the partial response with an instruction to continue from where it left off.
Continuation after an interrupted stream. The change is easy to miss because the old approach fails softly rather than erroring.

Carrying the older pattern forward onto a newer model does not raise an error. It produces a response that reads slightly wrong — restating, or resuming from the wrong place — which is far harder to attribute than an exception would be.

The second constraint is sharper: tool use and extended thinking blocks cannot be partially recovered. Only the most recent text block can be resumed. If the interruption landed mid tool-input, that call has to be re-run rather than repaired, which is worth knowing before designing a resume path that assumes every block is patchable.

Common mistakes

  1. Treating a 200 as proof of a complete response. Errors arrive as events after the status is fixed; only message_stop proves completion.
  2. Summing usage across message_delta events. The counts are cumulative, so summation inflates spend by an amount that grows with response length.
  3. Buffering all input_json_delta fragments into one string. Multiple blocks interleave and only the index separates them; concatenating produces invalid JSON for every call.
  4. Parsing tool input before content_block_stop. Fragments are deliberately partial, and a parse attempt mid-block fails on valid data.
  5. Throwing on unknown event types. New ones are added by design, and a strict switch turns a forward-compatible change into an outage.
  6. Carrying pre-4.6 continuation logic onto newer models. The failure is a subtly wrong answer, not an exception.
  7. Assuming a silent connection is a dead one. Gaps are expected while the model assembles tool input.
  8. Relying on browser EventSource auto-reconnect for LLM streams. It restarts the request rather than resuming the generation, so the user sees the answer begin twice and you pay for both.

Security and performance considerations

Streaming shifts what your users see before you have inspected it. In a non-streaming flow you can validate, redact, or reject a response before rendering; in a streaming flow the first tokens are on screen while the rest is still generating. Any moderation or PII filtering that assumed a complete document now has to work incrementally, or it is not running at the point where it matters.

Proxy rather than streaming straight to the browser

That also constrains what may be streamed directly to a browser. Proxying through your own server rather than exposing the provider endpoint keeps the API key server-side and gives you a place to enforce budget, apply filtering, and terminate a stream you have decided to stop — none of which is possible if the client holds the connection.

On performance, the honest benefit of streaming is perceived latency rather than total latency: the full response takes the same time, the first token simply arrives sooner. Weigh that against the handling complexity above for any non-interactive path. A background extraction job gains nothing from streaming and inherits every failure mode in this article — and if it is parsing the result, the guarantees discussed in what structured outputs actually guarantee are the more useful tool.

Troubleshooting

  1. Responses occasionally truncated with no error logged — a mid-stream error event being ignored. Assert on message_stop and log the error type.
  2. Recorded token usage far exceeds the provider invoice — cumulative usage being summed. Take the last value rather than accumulating.
  3. Tool calls fail with JSON decode errors — parsing before content_block_stop, or buffering multiple blocks together without keying on index.
  4. Tool calls work singly and break when the model makes two — interleaved blocks sharing one buffer. The index is the discriminator.
  5. Streams abort during tool use but not during text — an idle timeout shorter than the gaps the model takes while assembling tool input.
  6. Continuation after an interruption produces repeated or misaligned text — pre-4.6 continuation logic on a 4.6-or-later model.
  7. Mobile users see responses vanish mid-answer — the connection dropped on backgrounding, often with no error surfaced by the client.

The through-line is that streaming converts one atomic outcome into a protocol with its own state machine, and most of the bugs come from code that still assumes the atomic version. Handle the events the API documents — including the ones that only appear when something goes wrong — and the failure modes stop being mysterious.

If you are shipping a streaming assistant and want the failure modes handled before users find them, that is the sort of work our applied AI team does. Where the streaming boundary sits inside a larger service you already run, our backend and integration work covers that layer.

Frequently asked questions

Can a streaming LLM response fail after returning HTTP 200?
Yes, and this is the failure most handlers miss. The API can send an error event inside the stream — an overloaded_error, for example, which outside streaming would have been an HTTP 529 — long after the status was fixed at 200. Completion is proven by receiving the message_stop event, so any handler whose success path is simply falling out of the loop will treat truncated output as a finished response.
Why does my token count not match the provider's billing?
Almost certainly because usage counts in message_delta are cumulative rather than per-event. Summing them across events produces a running total of running totals, so the discrepancy grows with the number of deltas and therefore with response length. Take the value from the final message_delta, which already includes everything preceding it.
Why can I not parse tool call arguments while streaming?
Tool inputs arrive as input_json_delta events carrying partial JSON strings, and only the completed block forms a valid object. Accumulate the fragments and parse once content_block_stop arrives for that block. Crucially, accumulate keyed by the block index — a single response can contain several content blocks whose events interleave, and only the index distinguishes them.
How do I resume a stream that was interrupted?
Capture what arrived and issue a continuation request rather than regenerating. The mechanism differs by model generation: Claude 4.5 and earlier take the partial text as the start of a new assistant message, while 4.6 and later take a user message instructing the model to continue from where it left off. Tool use and extended thinking blocks cannot be partially recovered — only the most recent text block can resume.
Should I use the browser EventSource API for LLM streaming?
Be careful with its automatic reconnection, which is unhelpful here. EventSource reconnects on a dropped connection, but an LLM stream has no resume semantics at the transport level, so the request restarts and the user watches the answer begin a second time while you pay for both generations. Proxy through your own server so you control reconnection, keep the API key server-side, and can enforce budget limits.

References

  1. [1]Streaming messages — Anthropic, accessed 8 August 2026
  2. [2]Errors — Anthropic, accessed 8 August 2026

Revision history

  1. First published. Event flow, cumulative usage, error events and resume semantics verified against Anthropic's streaming documentation on the same date.

Share this article

Auravon AI

Auravon AI Editorial

Newsletter

Get Practical Engineering Insights

Articles like this one, delivered to your inbox. No filler, no news roundups — just engineering practice.