Skip to content

Routing and reliabilityGuide

Streaming LLM failures: interrupted streams and recovery

Streaming moves errors from the status line into the body. This guide shows how streams actually end, why a closed connection is not a success, and client code that tells complete answers from interrupted ones.

By INFRO Editorial

Published

9 min read

An amber signal waveform flowing across a timeline grid on a dark ground, representing a streamed model response

In short

A streamed response commits to 200 before the model has produced anything, so every later failure arrives as an error event, a dropped connection or silence. Treat a stream as complete only when you have seen its terminal event, give it an idle timeout, and on any other ending discard or clearly mark the partial output before retrying the whole request.

Key takeaways

  • In a stream the HTTP status is decided before the answer exists; failures after the first event arrive in the body, not the status line.
  • A stream is complete only when its terminal event arrived. End of file without one is a failed attempt, however much text came first.
  • Give every stream an idle timeout as well as a connect timeout; a silent connection is the failure mode that holds resources longest.
  • Never act on partial tool-call arguments, and replace rather than append when a retry regenerates a partial answer.
  • Retry an interrupted stream as a whole new request, except for errors that fail identically every time, such as a guardrail block.

With a non-streaming request, the status code is the verdict: 200 means you have the whole answer. With streaming, the server has to send its status line before the model has produced anything, so the 200 only says the stream opened. Everything that goes wrong afterwards — the provider overloads, a proxy drops an idle connection, the model goes quiet — has to be detected from the body.

Anthropic states the consequence directly: when a streamed response is in progress, an error can arrive after the API has already returned 200, and it does not follow the normal error-handling path1. Most client code that "handles errors" only handles the status code, and so records interrupted answers as successes.

Why a stream fails differently

Streaming LLM APIs use server-sent events: one long HTTP response carrying a sequence of data: lines, each event terminated by a blank line2. Three properties of the format shape every failure:

  • The status is already sent. Any error after the first byte has to be expressed as an event, or by closing the connection.
  • An incomplete event is discarded. If the connection ends in the middle of an event, before its blank line, the specification says the partial event is not dispatched2. A torn last line is lost, not half-parsed.
  • Silence is legal. Nothing in the format bounds the gap between events, and the specification notes that some legacy proxies drop connections that stay idle, suggesting a comment line every 15 seconds or so to keep them open2. A model that thinks for a minute before its next token looks, on the wire, exactly like a dead connection.

The four ways a stream ends

Every stream ends in one of four states, and only one of them is a complete answer.

How it endedWhat the client saw lastAnswer complete?What to do
Clean finishA finish_reason, then data: [DONE] (or message_stop)●CompleteKeep it; check finish_reason for length or content_filter
Error eventAn error payload instead of a chunk; no terminator○PartialDiscard or mark partial; retry per the error type
Connection closedEOF with no terminator and no error○PartialTreat as a failed attempt, never as success
StalledNothing for longer than your idle limit○UnknownAbort, then treat like a closed connection
Only a clean finish is a complete answer. The other three leave partial output that the application has to discard, mark or regenerate.

Each API marks a clean finish explicitly, which is what makes detection possible:

APIClean finishError in the streamAlso expect
OpenAI-compatible chat completionsA chunk with a non-null finish_reason, then data: [DONE]An event carrying an error object; no [DONE] follows (INFRO streaming)A usage chunk with empty choices when usage is requested
Anthropic Messagesmessage_stop, after message_delta events3An error event, for example overloaded_error3ping events, and new event types over time3
OpenAI Responsesresponse.completed4An error event4Many typed events for tools and content parts4
How three common streaming formats signal a complete response and an error.

Anthropic also asks clients to handle unknown event types gracefully, because new ones may be added3. A parser that throws on an event it has not seen turns a provider's additive change into your outage. Skip what you do not recognize; fail only on what you do.

The failures you will actually meet

  • A provider error mid-generation. The provider is overloaded or fails after it started. On Anthropic's API this arrives as an error event with the same types as HTTP errors3.
  • A dropped connection. A proxy, load balancer, mobile network or process restart closes the socket. There is no error event: the stream simply ends without its terminal marker.
  • A stall. The connection stays open and nothing arrives. Without an idle timeout the request, its socket and whatever the user is looking at wait indefinitely.
  • A truncated answer. The stream finishes cleanly with finish_reason: "length". The transport worked; the content is incomplete. Treat it as a distinct outcome, not a transient error.
  • A policy stop. A content filter or guardrail ends the response. Retrying the same request produces the same stop.
  • Partial tool arguments. Tool-call arguments stream as fragments of JSON and are only valid once the call finishes3. A stream that dies mid-call leaves a string that may parse, and still be wrong.

There is also a quieter cost. OpenAI points out that streaming makes output harder to moderate, because partial completions are harder to evaluate and moderation scores arrive only after the full output exists4. Whatever you showed before the failure was shown.

Detect the ending in the client

The reader below consumes an OpenAI-compatible chat stream and returns one of four outcomes rather than a string. Its idle timeout is httpx's read timeout, which bounds the wait for each read rather than the whole response, so a long answer that keeps arriving is never cut off while a silent one is.

stream_reader.py
import json
from dataclasses import dataclass
from typing import Callable

import httpx


@dataclass
class StreamResult:
    outcome: str                      # "complete" | "error" | "closed" | "stalled"
    text: str
    finish_reason: str | None = None
    error: dict | None = None
    usage: dict | None = None


def read_chat_stream(client: httpx.Client, url: str, api_key: str, body: dict, *,
                     idle_s: float = 60.0,
                     on_text: Callable[[str], None] | None = None) -> StreamResult:
    parts: list[str] = []
    finish: str | None = None
    usage: dict | None = None
    saw_done = False
    timeout = httpx.Timeout(10.0, read=idle_s)  # read= is the idle limit between chunks

    def result(outcome: str, error: dict | None = None) -> StreamResult:
        return StreamResult(outcome, "".join(parts), finish, error, usage)

    try:
        with client.stream("POST", url, json={**body, "stream": True},
                           headers={"Authorization": f"Bearer {api_key}"},
                           timeout=timeout) as resp:
            if resp.status_code >= 400:
                resp.read()
                resp.raise_for_status()   # failed before any event: an ordinary HTTP error
            for line in resp.iter_lines():
                if not line.startswith("data:"):
                    continue              # blank separators, ": comments", "event:" lines
                data = line[5:].strip()
                if data == "[DONE]":
                    saw_done = True
                    continue
                try:
                    event = json.loads(data)
                except json.JSONDecodeError:
                    return result("closed")   # a torn final line: the stream was cut
                if "error" in event:
                    return result("error", event["error"])
                if event.get("usage"):
                    usage = event["usage"]
                choices = event.get("choices") or []
                if not choices:
                    continue              # the usage chunk has no choices
                delta = (choices[0].get("delta") or {}).get("content")
                if delta:
                    parts.append(delta)
                    if on_text:
                        on_text(delta)
                if choices[0].get("finish_reason"):
                    finish = choices[0]["finish_reason"]
    except httpx.ReadTimeout:
        return result("stalled")
    except (httpx.RemoteProtocolError, httpx.ReadError):
        return result("closed")

    if saw_done and finish is not None:
        return result("complete")
    return result("closed")               # end of file without the terminator

The rule the function encodes is the whole point: complete requires both a finish reason and the terminator. Any other ending returns whatever text arrived, labelled as what it is, so the caller cannot mistake it for an answer.

Decide what to do with the partial output

What happens next depends on what the output was for.

The stream failed after some output arrived. What now?

  • The output drives a tool call or a writeDiscard it. Never act on arguments that did not finish
  • The error is a guardrail block or an invalid requestDo not retry; show a clear failure
  • A person is reading the text as it arrivesMark it interrupted, then offer or run a full retry
  • Background job, text onlyRetry the whole request with backoff; replace, never append
The decision after an interrupted stream. Output that drives an action is always discarded; text a person is reading is marked before it is replaced.

A retry is a new generation. It may produce different text, a different length and different tool calls, so the retry must replace the partial output rather than append to it. Appending the second attempt to the first produces an answer no model wrote.

retry_stream.py
import random
import time

NO_RETRY_TYPES = {"invalid_request_error", "authentication_error", "budget_exceeded",
                  "model_not_found", "permission_denied"}
NO_RETRY_CODES = {"guardrail_blocked", "guardrail_unavailable"}


def worth_retrying(r: StreamResult) -> bool:
    if r.outcome == "complete":
        return False
    if r.outcome == "error" and r.error:
        return (r.error.get("type") not in NO_RETRY_TYPES
                and r.error.get("code") not in NO_RETRY_CODES)
    return True                           # closed or stalled


def stream_with_retry(client, url, api_key, body, render, *, attempts: int = 3) -> StreamResult:
    """render: your UI or buffer, with reset(), write(text) and mark_interrupted(result)."""
    for attempt in range(attempts):
        render.reset()                    # replace, never append
        r = read_chat_stream(client, url, api_key, body, on_text=render.write)
        if not worth_retrying(r) or attempt == attempts - 1:
            if r.outcome != "complete":
                render.mark_interrupted(r)
            return r
        time.sleep(random.uniform(0, min(8.0, 0.5 * 2 ** attempt)))  # full jitter
    raise RuntimeError("unreachable")

For a chat interface, a visible "interrupted, regenerating" state is better than text that silently rewrites itself under the reader. For a background job, discard and regenerate. For anything that calls a tool or writes data, never act on output from a stream that did not complete.

Anthropic documents one alternative to regenerating from scratch: capture the partial text and send a continuation request, as an assistant prefix for Claude 4.5 and earlier models or as a user instruction to continue for 4.6 and later3. It saves regenerating what already arrived, at the price of a second billed request whose input includes the partial answer, and it does not work for tool-use or thinking blocks, which cannot be partially recovered3. Use it for long text where a seam is acceptable; regenerate everywhere else.

What INFRO sends when a stream fails

INFRO streams chat completions in the OpenAI-compatible format, so the reader above works against it unchanged (streaming). Its behaviour splits at the first event:

Client

Gateway

Provider A

Provider B

  1. Client to Gateway: POST stream: true
  2. Gateway to Provider A: Attempt 1
  3. Provider A to Gateway: 503 before any event
  4. Gateway to Provider B: Attempt 2
  5. Provider B to Gateway: First event
  6. Gateway to Client: Stream opens: the commit point
  7. Provider B to Gateway: Connection drops mid-answer
  8. Gateway to Client: Error event, then close without [DONE]
The commit point in a gateway stream. A failure before the first event moves to another provider account without the client seeing it; a failure after it reaches the client as an error event.
  • Before the first event, a provider failure does not reach you. The gateway reads the upstream's first event inside the attempt, so a provider that returns an error, or fails or falls silent before it has produced one, is abandoned and the request moves to the next of your connections, or the next target on the route. The only symptom is a later first chunk. If nothing can serve, you get a normal HTTP error status with a JSON body (errors).
  • After the first event, INFRO does not restart the response on another provider. If the serving provider drops, it sends a final event whose payload is the standard error object and closes the stream without data: [DONE]; the usage chunk you asked for never arrives.
  • A stall longer than the connection's timeout, and never less than 120 seconds, ends the stream the same way, with type request_timeout and code stream_stalled. The floor is deliberate: reasoning models can pause for a long time between events. If your interface needs a shorter limit, set it in the client, as the reader above does.
  • An output guardrail holds back the last few characters before releasing them, and a block match ends the stream with guardrail_blocked. Text sent before the match was already delivered, and a retry will be stopped the same way.

Every chunk carries the request id, also returned in the X-INFRO-Request-Id header. GET /v1/requests/{id} then shows the connection that served, the attempts that failed before the stream opened, and the error that ended it, so an interrupted answer in a support ticket can be traced to the provider account that dropped it (traces).

Checklist

  • Treat a stream as complete only after its terminal event: [DONE], message_stop or response.completed.
  • Set an idle timeout between events as well as a connect timeout, sized for the slowest model you call.
  • Skip unknown event types and comment lines instead of failing on them.
  • Return an outcome, not a string, from your stream reader, and record the outcome.
  • Discard partial tool-call arguments; never execute a call from an incomplete stream.
  • Replace partial output on retry, and show an interrupted state in any interface that rendered it.
  • Exclude guardrail, authentication, budget and invalid-request errors from automatic retry.
  • Handle finish_reason: "length" as a truncated answer, not a transient failure.
  • Log the request id of every interrupted stream so it can be traced later.

Questions readers ask

Can I resume an interrupted LLM stream instead of starting again?
Not at the protocol level: SSE reconnection does not resume a model generation. Anthropic documents a continuation approach that sends the partial text back in a new request, but it is a new, billed generation, and tool-use and thinking blocks cannot be partially recovered.
Why did my stream stop with finish_reason length?
The model reached the output token limit you set or the model's maximum. The transport worked; the answer is truncated. Raise max_tokens for that workload or ask for shorter output, and do not treat it as a transient error to retry.

From the team that builds it

Where this shows up in INFRO

INFRO is a control plane in front of the provider accounts you already hold. The capabilities below are the parts of it this article touches, each with the documentation that defines its behaviour.

  • Text — OpenAI-compatible

    Private early access

    POST /v1/chat/completions with the request shape the OpenAI SDKs already send, including streaming and tool calling.

    Read the documentation for Text — OpenAI-compatible
  • Automatic failover

    Private early access

    A route that times out or 5xxs before the response starts is retried elsewhere without your client seeing it.

    Read the documentation for Automatic failover
  • Request traces and payload logs

    Private early access

    Per-request timing detail, with prompt and completion logging off by default and a retention window you choose.

    Read the documentation for Request traces and payload logs
  • Guardrails

    Private early access

    Pattern-based checks on chat completion prompts and responses: block, redact or flag email addresses, phone numbers, card numbers, IBANs, US Social Security numbers, IP addresses, API keys and your own terms, plus optional classifier checks on your own connection to a decision model's provider. Detection can miss things. Included from the Business plan.

    Read the documentation for Guardrails

INFRO is in private early access. Provider usage is billed to you by your providers; INFRO charges a software subscription and takes 0% of model spend. See plans.

Sources

Numbered in order of first citation. Each was opened and checked against the claim on the date shown.

  1. 1.Claude API errors (opens in a new tab) — Anthropic. Accessed Sep 29, 2026.
  2. 2.HTML Living Standard: Server-sent events (opens in a new tab) — WHATWG. Accessed Sep 29, 2026.
  3. 3.Streaming messages (opens in a new tab) — Anthropic. Accessed Sep 29, 2026.
  4. 4.Streaming API responses (opens in a new tab) — OpenAI. Accessed Sep 29, 2026.

About this article

Written from the server-sent events specification, the streaming and error documentation of Anthropic and OpenAI, and the INFRO gateway's streaming documentation and source, which the authors build. The client code was written for this article against the documented wire formats; adapt the parsing to the event format of the API you call.

Last checked against its sources on . The next review is due by . No separate named reviewer is recorded for this article.

How articles are written, sourced and corrected is set out in the editorial standards.