How to Debug Streaming Failures in Claude, GPT, and Gemini (SSE Drops, Partial Output, Stalls) | AI Error Hub
AI Error Hub/Blog/Debugging Streaming Failures
Troubleshooting · Code

How to Debug Streaming Failures in Claude, GPT, and Gemini (SSE Drops, Partial Output, Stalls)

Streaming bugs are the hardest LLM bugs to reproduce. The request returns 200, the first words arrive, and then the answer cuts off mid-sentence, hangs forever, or ends cleanly with half the JSON missing. This post gives you a method: find out which of three failure shapes you have, reproduce it outside your stack, add the instrumentation that tells drops from stalls from limits, and fix it at the right layer for Claude, GPT, and Gemini.

ZK
Zain Khan
Editor, AI Error Hub · October 13, 2026 · 17 min read

The three failure shapes

"Streaming is broken" covers at least three different problems with different fixes. Name the one you have before changing any code.

ShapeWhat you seeWhat actually happenedFix lives in
DropText stops mid-answer, sometimes with a network errorThe connection closed before the provider's terminal eventNetwork path, timeouts, retry logic
StallSpinner forever, or a long pause, then nothingThe connection is open but no content is arrivingWatchdog timers, proxy idle timeouts, provider load
Early finishAnswer ends cleanly but is cut off or empty, JSON will not parseThe stream completed normally, but the model stopped on a limit or a filterRequest parameters, not the network

A fourth case hides inside the first. Because the HTTP status is sent before generation starts, a provider failure partway through arrives as an event in the body, not as a status code. Code that only checks response.status_code never sees it.

The rule that makes everything else easy: a stream is complete only if you saw the provider's terminal marker. A connection that simply closes is a drop, whatever the client library says.

What each provider's stream looks like

Debugging starts with knowing what a healthy stream ends with.

ClaudeGPT (chat completions)Gemini
Endpoint switch"stream": true"stream": true:streamGenerateContent?alt=sse
Event framingNamed events (event: plus data:)Unnamed data: linesUnnamed data: lines
Text arrives incontent_block_delta with text_deltachoices[0].delta.contentcandidates[0].content.parts[].text
Stop reason arrives inmessage_deltaLast choice chunk, finish_reasonLast chunk, finishReason
Terminal markermessage_stopdata: [DONE]A chunk with finishReason (no sentinel)
Keepaliveping eventsSSE comment lines may appearNone to rely on
Error after the 200error event, for example overloaded_errorAn error object in a chunkAn error object, or the connection just ends
Usage numbersIn message_start and message_deltaFinal chunk, only with include_usageusageMetadata on chunks

Gemini is the one that bites people, because with no [DONE] equivalent a connection that dies after a chunk full of text looks exactly like a finished answer unless you check for finishReason. If you use the OpenAI Responses API instead of chat completions, look for the typed terminal events (response.completed, response.incomplete, response.failed) rather than [DONE].

Step 1: Reproduce with curl, outside your stack

Before reading your own code, take your code out of the picture. -N disables curl's output buffering so you see events as they arrive, and -D - prints the response headers, which include the request ID you will need if you contact support.

# Claude
curl -N -sS -D - https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{"model":"'"$MODEL"'","max_tokens":256,"stream":true,
       "messages":[{"role":"user","content":"Count from 1 to 20, one number per line."}]}'

# OpenAI (chat completions)
curl -N -sS -D - https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "content-type: application/json" \
  -d '{"model":"'"$MODEL"'","stream":true,"stream_options":{"include_usage":true},
       "messages":[{"role":"user","content":"Count from 1 to 20, one number per line."}]}'

# Gemini
curl -N -sS -D - "https://generativelanguage.googleapis.com/v1beta/models/$MODEL:streamGenerateContent?alt=sse" \
  -H "x-goog-api-key: $GEMINI_API_KEY" \
  -H "content-type: application/json" \
  -d '{"contents":[{"role":"user","parts":[{"text":"Count from 1 to 20, one number per line."}]}]}'

Set MODEL to the model you actually use. Then read the result:

  • Events arrive one by one and finish with the terminal marker. The provider is fine. Repeat the request against your own streaming endpoint with the same curl -N. If that one drops or arrives in a single burst at the end, the bug is in your infrastructure or relay code.
  • Everything arrives in one burst after a long wait. Something is buffering. This is the classic reverse proxy or compression problem covered in Step 2.
  • The stream fails here too. Check the provider status page, save the request ID from the headers, and move on to the mid-stream error handling in Step 4.

Step 2: Find the layer that is killing it

Between your user and the model there can be six hops, and each one has its own idea of how long an idle connection may live or whether a response may be buffered. Work from the outside in.

LayerTypical symptomWhat to check
Browser or mobile clientDrops on tab switch, sleep, or network changeHandle visibility changes and offline events, send a done event so drops are detectable
CDN or edge proxyStream dies after a fixed time, such as 100 s on Cloudflare plans without a raised limit, or 30 s origin response timeouts on CloudFront by defaultRaise origin timeouts, send keepalive comments, or bypass the CDN for the streaming path
Load balancerDrops after about a minute of silence (AWS ALB idle timeout defaults to 60 s)Raise the idle timeout above your longest silence, and send a heartbeat
Reverse proxy (nginx and similar)Whole answer arrives at once at the endproxy_buffering off, a long proxy_read_timeout, and the X-Accel-Buffering: no response header
Compression middlewareChunks held back until a buffer fillsExclude text/event-stream from gzip and brotli
Serverless platformHard cutoff at a fixed duration, or no streaming support on the runtimeCheck the platform's maximum function duration and whether the runtime supports streamed responses
Your HTTP clientReadTimeout exactly N seconds into a quiet stretchRead timeouts apply per chunk, not to the whole response; set them deliberately

The tell for an infrastructure timeout is a failure at a suspiciously round number: exactly 30, 60, or 100 seconds from the start or from the last byte. Put the elapsed time in your logs and look for clusters.

Reasoning models can sit silent for a long time before the first visible token. That silence can look identical to an idle timeout. If streams for thinking-enabled requests fail at a fixed duration while fast requests are fine, heartbeats and raised idle timeouts are the fix, not retries.

Step 3: Parse the stream strictly

Many SDKs hide the raw stream and quietly end the iterator when the connection closes. To tell a drop from a normal finish you need a parser that knows the difference. The one below follows the SSE rules: events end at a blank line, lines starting with a colon are comments, multiple data: lines join with newlines, and a half-finished event at the end of the connection is an error rather than silently discarded.

from dataclasses import dataclass
from typing import AsyncIterator, Optional


class TruncatedStream(Exception):
    pass


@dataclass
class SSEEvent:
    event: str
    data: str
    id: Optional[str] = None


async def parse_sse(lines: AsyncIterator[str]) -> AsyncIterator[SSEEvent]:
    event, data, last_id = "message", [], None
    async for raw in lines:
        line = raw.rstrip("\r\n")
        if line == "":                       # blank line ends one event
            if data:
                yield SSEEvent(event, "\n".join(data), last_id)
            event, data = "message", []
            continue
        if line.startswith(":"):             # comment / keepalive, not an event
            continue
        field, _, value = line.partition(":")
        value = value.removeprefix(" ")
        if field == "event":
            event = value
        elif field == "data":
            data.append(value)
        elif field == "id":
            last_id = value
    if data:                                 # connection ended mid-event
        raise TruncatedStream("stream ended in the middle of an event")

Step 4: Track state, including errors inside the stream

The tracker reduces each provider's events to the same few facts: the text so far, the stop reason, whether the terminal marker arrived, and whether an error event came through. It is the only code that knows provider-specific event names, so everything downstream stays uniform.

import json


class StreamTracker:
    def __init__(self, provider: str):
        self.provider = provider
        self.text: list[str] = []
        self.stop: Optional[str] = None
        self.done = False                    # terminal marker seen
        self.error: Optional[dict] = None    # error delivered inside the stream
        self.events = 0
        self.last_event: Optional[str] = None

    def feed(self, ev: SSEEvent) -> None:
        self.events += 1
        self.last_event = ev.event
        getattr(self, "_" + self.provider)(ev)

    def _anthropic(self, ev):
        d = json.loads(ev.data)
        t = d.get("type")
        if t == "content_block_delta" and d["delta"].get("type") == "text_delta":
            self.text.append(d["delta"]["text"])
        elif t == "message_delta":
            self.stop = d["delta"].get("stop_reason")
        elif t == "message_stop":
            self.done = True
        elif t == "error":                   # arrives after HTTP 200
            self.error = d.get("error", {})

    def _openai(self, ev):
        if ev.data.strip() == "[DONE]":
            self.done = True
            return
        d = json.loads(ev.data)
        if "error" in d:
            self.error = d["error"]
            return
        for ch in d.get("choices", []):
            piece = (ch.get("delta") or {}).get("content")
            if piece:
                self.text.append(piece)
            if ch.get("finish_reason"):
                self.stop = ch["finish_reason"]

    def _gemini(self, ev):
        d = json.loads(ev.data)
        if "error" in d:
            self.error = d["error"]
            return
        block = (d.get("promptFeedback") or {}).get("blockReason")
        if block:
            self.stop, self.done = "blocked:" + block, True
            return
        for c in d.get("candidates", []):
            for p in (c.get("content") or {}).get("parts", []):
                if "text" in p and not p.get("thought"):
                    self.text.append(p["text"])
            if c.get("finishReason"):        # Gemini has no [DONE] sentinel
                self.stop, self.done = c["finishReason"], True

Two details are worth calling out. Gemini's thought parts are skipped so reasoning summaries do not leak into user-visible text. And a Gemini prompt-level blockReason is treated as a terminal state, because no candidates will follow it.

Tool calls need extra care. Claude streams tool input as input_json_delta fragments of a JSON string that is only valid once the content block closes. OpenAI streams tool_calls deltas keyed by index, with the arguments string arriving in pieces. In both cases, parse the arguments only after the terminal marker, and never execute a tool from a partial argument string. If the stream dropped mid-tool-call, throw the whole turn away.

Step 5: Detect stalls with a progress watchdog

An open connection is not progress. The watchdog below enforces three deadlines: one for the first content token, a shorter one between content events afterwards, and a ceiling for the whole response. On Claude it ignores ping events when judging progress, since pings prove the socket is alive and not that the model is generating.

import asyncio, time


class StallError(Exception):
    pass


def is_progress(provider: str):
    if provider == "anthropic":              # pings do not count as progress
        return lambda ev: ev.event == "content_block_delta"
    return lambda ev: True


async def watch(events, progress, first_s=20.0, idle_s=30.0, total_s=300.0):
    it = events.__aiter__()
    start = last = time.monotonic()
    seen = False
    while True:
        now = time.monotonic()
        limit = first_s if not seen else idle_s
        budget = min(limit - (now - last), total_s - (now - start))
        if budget <= 0:
            raise StallError("no first token" if not seen else "no progress")
        try:
            ev = await asyncio.wait_for(it.__anext__(), timeout=budget)
        except StopAsyncIteration:
            return
        except asyncio.TimeoutError:
            raise StallError("no first token" if not seen else "no progress")
        if progress(ev):
            seen = True
            last = time.monotonic()
        yield ev

Pick the first-token deadline per model. A fast chat model might need 10 to 20 seconds. A reasoning model with a large thinking budget might need a couple of minutes. Seed these numbers from your own p99 time-to-first-token, and revisit them when you change models.

Step 6: Classify every stream when it ends

Now you can turn the tracker and any exception into one of six outcomes, and log it with the numbers you need later. This is the function that turns "streaming is flaky" into a chart you can read.

import httpx

LIMIT = {"max_tokens", "length", "MAX_TOKENS", "model_context_window_exceeded"}
FILTER = {"refusal", "content_filter", "SAFETY", "RECITATION", "BLOCKLIST",
          "PROHIBITED_CONTENT", "SPII"}


def classify(tr: StreamTracker, exc: Optional[BaseException] = None) -> str:
    if isinstance(exc, StallError):
        return "stalled"
    if tr.error:
        return "error_event"                 # provider failed after the 200
    if exc is not None or not tr.done:
        return "dropped"                     # no terminal marker
    if tr.stop in LIMIT:
        return "length_limit"
    if tr.stop in FILTER or (tr.stop or "").startswith("blocked:"):
        return "filtered"
    return "ok"


@dataclass
class Trace:
    provider: str
    status: int = 0
    request_id: Optional[str] = None
    ttfb_s: float = 0.0
    ttft_s: float = 0.0
    duration_s: float = 0.0
    outcome: str = ""


TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)


async def consume(provider, url, headers, body, on_text, trace: Trace):
    tr = StreamTracker(provider)
    t0 = time.monotonic()
    exc = None
    try:
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            async with client.stream("POST", url, headers=headers, json=body) as r:
                trace.status = r.status_code
                trace.request_id = r.headers.get("request-id") or r.headers.get("x-request-id")
                trace.ttfb_s = time.monotonic() - t0
                if r.status_code != 200:
                    raise RuntimeError(f"HTTP {r.status_code}: {(await r.aread())[:500]!r}")
                events = parse_sse(r.aiter_lines())
                async for ev in watch(events, is_progress(provider)):
                    before = len(tr.text)
                    tr.feed(ev)
                    if len(tr.text) > before:
                        if not trace.ttft_s:
                            trace.ttft_s = time.monotonic() - t0
                        await on_text(tr.text[-1])
    except (StallError, TruncatedStream, httpx.TransportError) as e:
        exc = e                               # ReadError, RemoteProtocolError, ReadTimeout...
    trace.duration_s = time.monotonic() - t0
    trace.outcome = classify(tr, exc)
    return tr, trace

Note what is caught: httpx.TransportError covers ReadError, ReadTimeout, and RemoteProtocolError, which is what you see when the peer closes mid-chunk ("peer closed connection without sending complete message body"). All of those are drops.

Step 7: Decide what to do for each outcome

The right recovery depends on the outcome and on one more fact: has any text already reached the user?

OutcomeNothing shown yetText already shown
droppedRetry, or fail over to another providerRetry the whole request and replace the partial text, or keep the partial and mark it incomplete. Do not splice two generations together
stalledCancel, retry once, then fail overCancel and show a "tap to retry" state. Retrying silently can double the wait
error_eventTreat like the matching HTTP error: back off on overloaded and rate-limit errors, never retry authentication errorsSame classification, but surface the partial output as incomplete
length_limitRaise the output limit or shorten the taskShow the answer with a "continued" action, or run a continuation request for plain text
filteredDo not retry the same prompt unchangedShow what was produced and explain the stop
okDoneDone

For plain text, a continuation request that sends the partial answer back and asks the model to pick up where it left off can work, but expect small seams, and never do it for JSON, code that must compile, or anything with tool calls. For those, retry from the top. If you already have a multi-provider layer, the failover rule is the same one covered in our provider-agnostic client post: switch providers only before the first token reaches the user.

For overload and rate-limit errors that arrive mid-stream, apply the same backoff and jitter you would use for non-streaming calls. The Bulletproof Claude API Wrapper post covers that logic in detail.

Stop reasons that look like failures but are not

A large share of "my stream got cut off" reports are actually normal finishes. Check the stop reason before debugging the network.

MeaningClaudeGPTGemini
Natural endend_turnstopSTOP
Output limit hitmax_tokenslengthMAX_TOKENS
Context window exhaustedmodel_context_window_exceededlength (or an error before the stream)An error before the stream, or MAX_TOKENS
Tool call requestedtool_usetool_callsA functionCall part with STOP
Declined or blockedrefusalcontent_filterSAFETY, RECITATION, BLOCKLIST, PROHIBITED_CONTENT

The most common version of this bug is structured output that "fails to parse" because it was truncated at the output limit. The stream was healthy. Check for max_tokens, length, or MAX_TOKENS, raise the limit, and reduce the size of what you ask for. Reasoning and thinking tokens count against the output budget on several models, so a limit that was generous for plain answers can be too small once thinking is enabled.

Step 8: Fix your own relay

If your backend sits between the provider and the browser, it is the most common place to introduce drops and stalls. A correct relay does four things: it flushes each piece immediately, sends a heartbeat when upstream is quiet, cancels the upstream request when the user disconnects, and ends with an explicit done event.

import asyncio, json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

app = FastAPI()


async def relay(upstream, req: Request, heartbeat_s: float = 15.0):
    it = upstream.__aiter__()
    task = None
    try:
        while True:
            task = asyncio.ensure_future(it.__anext__())
            while True:
                done, _ = await asyncio.wait({task}, timeout=heartbeat_s)
                if done:
                    break
                if await req.is_disconnected():
                    return                    # user left: stop paying for tokens
                yield ": keepalive\n\n"       # keeps proxies from idling out
            try:
                piece = task.result()
            except StopAsyncIteration:
                yield "event: done\ndata: {}\n\n"
                return
            except Exception as e:
                yield "event: error\ndata: " + json.dumps({"message": str(e)}) + "\n\n"
                return
            yield "data: " + json.dumps({"t": piece}) + "\n\n"
    finally:
        if task and not task.done():
            task.cancel()
        await it.aclose()


@app.post("/chat")
async def chat(req: Request):
    body = await req.json()
    return StreamingResponse(
        relay(stream_text(body), req),        # stream_text: your async generator of str
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

The explicit done event is what gives your own frontend the same terminal-marker rule you applied to the provider. Without it, a browser cannot tell a finished answer from one cut off by a network blip.

On the client, avoid EventSource for chat. It only issues GET requests and reconnects automatically, which replays a request you did not want replayed. Read a fetch stream and check for the done event:

async function streamChat(prompt, onText) {
  const res = await fetch("/chat", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ prompt }),
  });
  if (!res.ok || !res.body) throw new Error("HTTP " + res.status);

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buf = "", sawDone = false;

  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    let i;
    while ((i = buf.indexOf("\n\n")) !== -1) {
      const block = buf.slice(0, i);
      buf = buf.slice(i + 2);
      let name = "message", data = "";
      for (const line of block.split("\n")) {
        if (line.startsWith(":")) continue;
        if (line.startsWith("event: ")) name = line.slice(7);
        if (line.startsWith("data: ")) data += line.slice(6);
      }
      if (name === "done") sawDone = true;
      else if (name === "error") throw new Error(JSON.parse(data).message);
      else if (data) onText(JSON.parse(data).t);
    }
  }
  if (!sawDone) throw new Error("stream ended without a done event");
}

What to log on every stream

You can debug almost any streaming incident in minutes if each request leaves one structured log line with these fields:

  • Provider request ID from the response header (request-id on Claude, x-request-id on OpenAI). It is the first thing any provider support team will ask for.
  • Time to first byte and time to first content token. The gap between them points at buffering or provider queueing.
  • Event count, byte count, and last event type seen. A stream whose last event was content_block_delta was cut mid-answer. One whose last event was message_delta lost only the closing event.
  • Terminal marker seen, stop reason, and outcome from the classifier above.
  • Total duration. Failures clustering at the same duration are timeouts.
  • Client type and network path, so you can separate mobile Safari from a server-to-server job.

Chart the outcome by hour and by route. A sudden rise in dropped after a deploy points at your relay or proxy configuration. A rise in error_event that matches a provider incident points at the provider. A rise in length_limit after a prompt change points at your prompt.

Symptom-to-cause cheat sheet

SymptomMost likely causeFirst thing to try
Whole answer appears at once after a delayProxy or compression bufferingX-Accel-Buffering: no, disable gzip for event streams
Fails at exactly 30, 60, or 100 secondsIdle or origin timeout on a CDN or load balancerHeartbeat comments and raised timeouts
Cuts off mid-sentence, then a network exceptionDropped connectionCheck for the terminal marker and retry the request
Cuts off mid-sentence with no exceptionOutput limit, or a Gemini stream that died without a finish reasonLook at the stop reason, then at finishReason presence
Spinner forever, no errorStall with no deadlineAdd the watchdog and an overall deadline
JSON parse error on a "successful" callTruncation at max_tokens or lengthRaise the limit, shrink the output, validate before use
Error text appears inside the answerError event treated as contentHandle the error event type separately
Works on Wi-Fi, drops on mobileNetwork changes or carrier proxiesClient-side drop detection and a retry action
Costs higher than the visible output suggestsDropped streams were still generated upstreamCancel upstream on user disconnect

Test the failures before they happen in production

Streaming code is easy to get wrong because the happy path always works. Test the unhappy paths with scripted streams, no network required:

import pytest

def delta(text):
    return SSEEvent("content_block_delta",
        json.dumps({"type": "content_block_delta", "index": 0,
                    "delta": {"type": "text_delta", "text": text}}))


async def lines(*chunks):
    for c in chunks:
        yield c


@pytest.mark.asyncio
async def test_half_event_is_truncation():
    with pytest.raises(TruncatedStream):
        async for _ in parse_sse(lines("event: message_start", 'data: {"type":"mess')):
            pass


def test_missing_message_stop_is_a_drop():
    tr = StreamTracker("anthropic")
    tr.feed(delta("Hel"))
    assert classify(tr) == "dropped"


def test_error_event_after_200():
    tr = StreamTracker("anthropic")
    tr.feed(delta("Hel"))
    tr.feed(SSEEvent("error", '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}'))
    assert classify(tr) == "error_event"


def test_length_stop_is_not_a_failure_to_retry():
    tr = StreamTracker("openai")
    tr.feed(SSEEvent("message", '{"choices":[{"delta":{"content":"x"},"finish_reason":"length"}]}'))
    tr.feed(SSEEvent("message", "[DONE]"))
    assert classify(tr) == "length_limit"


async def hangs_after(n):
    for _ in range(n):
        yield delta("hi ")
    await asyncio.sleep(3600)


@pytest.mark.asyncio
async def test_stall_is_detected():
    with pytest.raises(StallError):
        async for _ in watch(hangs_after(2), lambda e: True,
                             first_s=0.2, idle_s=0.2, total_s=5):
            pass

Add one more layer if you can: a tiny local server that serves a recorded real stream, with switches to truncate it after N events, delay a chunk by 60 seconds, or append an error event. Point your staging environment at it and confirm that your proxy, relay, and frontend each behave correctly.

Pitfalls to avoid

  • Trusting the SDK iterator to raise on a drop. Some paths end quietly. Check the terminal marker yourself.
  • One timeout for everything. A single "request timeout" cannot distinguish a slow first token from a stalled stream. Use first-token, inter-chunk, and total deadlines.
  • Retrying after text is on screen. The user sees the answer start over, or worse, get stitched. Decide on a policy and make it visible in the UI.
  • Executing a tool from partial arguments. Parse and act only after the terminal marker.
  • Forgetting to cancel upstream. A user who closes the tab should not cost you the remaining tokens.
  • Treating every 200 as success. Check the terminal marker, the stop reason, and any error event.
  • Debugging through three proxies at once. Always reproduce with curl against the provider directly first.

If your stream failures cluster around provider load, the failover design in the Multi-Provider Load Balancer post is the next layer to add.

Frequently asked questions

Why does my stream work locally but drop in production?

Almost always an intermediary. Reverse proxies buffer or time out idle connections, compression middleware holds chunks back, and serverless platforms cap total duration. Run the same request with curl -N directly against the provider and then against your own endpoint. If the first works and the second does not, the problem is your infrastructure, not the model API.

Is a stream that ends without an error a successful stream?

No. A clean TCP close is not a completion signal. Claude streams end with a message_stop event, OpenAI chat streams end with data: [DONE], and Gemini streams end with a chunk that carries a finishReason. If you did not see the terminal marker, treat the response as incomplete.

What is the difference between a dropped stream and a truncated one?

A dropped stream lost its connection before the terminal event, so the output is incomplete by accident. A truncated stream finished normally but the model stopped because it hit a limit, which shows up as max_tokens on Claude, length on OpenAI, or MAX_TOKENS on Gemini. Drops are retried. Truncation is fixed by raising the limit or shortening the task.

Can I get an error after the HTTP 200 has already been sent?

Yes. Once streaming starts the status code is fixed, so later failures arrive in the stream body instead. Claude sends an error event, for example with an overloaded_error payload. Code that only checks the HTTP status will miss these, which is why the tracker in this post records error events too.

How long should the stall timeout be?

Use two numbers. A first-token deadline that fits the model, since reasoning models can legitimately think for a while before the first visible token, and a shorter inter-chunk deadline once output is flowing. Thirty seconds between content chunks is a reasonable default for chat. Tune from your own p99 time-to-first-token and chunk-gap metrics rather than copying a constant.

Do keepalive pings mean the model is still working?

Not necessarily. Pings and SSE comment lines prove the connection is open, not that generation is progressing. Measure the stall timer against content events only, and treat pings as a separate connection-health signal.

Can I resume a stream from where it dropped?

Not through the providers' APIs. There is no standard resume token. For plain text you can send the partial answer back as context and ask the model to continue, then stitch the pieces, but quality can drift. For tool calls and structured output, discard the partial result and retry the whole request.

Will I be billed for a stream that dropped?

Usually you pay for the tokens the provider generated, even if your client never received them all, and the usage numbers normally arrive in the final events you did not see. Cancel the upstream request promptly when your user disconnects, and track cost from your provider's dashboard as well as from client-side counts.

Should I use EventSource in the browser?

EventSource only supports GET requests and reconnects automatically, which is wrong for a chat POST that must not be replayed. Use fetch with a readable stream, parse the events yourself, and send an explicit done event from your server so the client can tell completion from a drop.

What should I log for every streamed request?

The provider's request ID header, time to first byte, time to first content token, event and byte counts, the last event type seen, whether the terminal marker arrived, the stop reason, total duration, and how the stream ended. With those fields almost every streaming incident can be classified in a few minutes.

Keep reading