n4nAI

Tracking streaming completion rates and dropped connections

Learn how to instrument and track streaming completion rates and dropped connections for LLM APIs, with practical code and monitoring patterns.

n4n Team5 min read1,000 words

Audio narration

Coming soon — every post will get a voice note here.

Streaming LLM responses over HTTP introduces failure modes that classic request/response monitoring misses. If you operate a service that consumes token streams, tracking streaming completion rates dropped connections is the only way to see partial failures, silent truncations, and client aborts that never surface as HTTP 500s.

Define a stream completion event

A stream is complete when the upstream sends a terminating chunk with a finish_reason and the TCP connection closes cleanly. Anything else—connection reset, timeout before final chunk, client cancel—is a drop.

In the OpenAI streaming format, the last relevant chunk looks like:

{
  "choices": [
    {
      "delta": {},
      "finish_reason": "stop",
      "index": 0
    }
  ],
  "id": "chatcmpl-123",
  "object": "chat.completion.chunk"
}

If you only count HTTP 200 responses, you will miss cases where the connection died mid-stream and the client never parsed a finish_reason. That gap is exactly why streaming completion rates dropped connections must be measured at the application layer, not the load balancer.

What counts as a drop

  • Upstream closes TCP socket before sending finish_reason.
  • Read timeout expires while waiting for the next chunk.
  • Client disconnects (browser close, mobile background).
  • Proxy or gateway returns 502/503 after streaming starts.

All four reduce the effective completion rate even if the initial HTTP status was 200.

Instrument the client stream reader

Wrap your streaming call so every chunk increments a counter and a timer starts on the first byte. Capture the asyncio.CancelledError or equivalent so intentional client disconnects are logged as drops, not ignored.

import time
import asyncio
from openai import AsyncOpenAI

client = AsyncOpenAI()

async def stream_with_metrics(messages, model):
    start = time.monotonic()
    chunks = 0
    finished = False
    try:
        stream = await client.chat.completions.create(
            model=model, messages=messages, stream=True
        )
        async for chunk in stream:
            chunks += 1
            if chunk.choices[0].finish_reason:
                finished = True
        duration = time.monotonic() - start
        if finished:
            # record completion
            pass
        else:
            # record dropped stream (no finish_reason)
            pass
    except asyncio.CancelledError:
        # client disconnected
        finished = False
        raise
    except Exception:
        # upstream/network error
        finished = False
        raise
    finally:
        # emit metric: chunks, duration, finished flag
        pass

Pitfall: SDK exception masking

Many SDKs swallow connection resets into a generic APIError after the fact. You must inspect whether any finish_reason was seen. If not, count it as a drop even if the exception says “stream closed”. Do not rely on the exception type alone.

Idle timeout handling

Set a per-chunk idle timeout (e.g., 30s). If no chunk arrives within that window, force-close and mark the stream dropped. A slow stream that eventually finishes is not a drop; a stalled one is.

Log stream lifecycle at the proxy

If you front requests with a reverse proxy or API gateway, emit a structured log line when the upstream stream ends or the client socket closes. Below is a minimal FastAPI middleware pattern that tracks active streams and logs termination cause.

from fastapi import Request
import logging

logger = logging.getLogger("stream_monitor")

@app.middleware("http")
async def stream_tracker(request: Request, call_next):
    if request.url.path == "/v1/chat/completions":
        response = await call_next(request)
        # real impl uses request.is_disconnected() in a background task
        return response
    return await call_next(request)

A more robust approach uses request.is_disconnected() in a background task that watches the stream coroutine. When the client goes away, you log client_abort. When upstream closes early, you log upstream_drop.

Why proxy logs are incomplete

Standard access logs record the response status after the handler returns. For streaming, the handler may return 200 before the stream body is fully sent. You need either an asynchronous response wrapper or an eBPF socket observer to catch mid-stream resets.

Compute the completion rate metric

Export two counters to Prometheus: streams_started_total and streams_completed_total. The streaming completion rates dropped connections ratio is streams_completed_total / streams_started_total over a window.

# prometheus metrics example
streams_started_total{model="gpt-4o"} 1420
streams_completed_total{model="gpt-4o"} 1390
streams_dropped_total{model="gpt-4o",cause="upstream"} 22
streams_dropped_total{model="gpt-4o",cause="client"} 8

In Grafana, plot:

sum(rate(streams_completed_total[5m])) / sum(rate(streams_started_total[5m]))

Tradeoff: label cardinality

Adding high-cardinality labels like user_id will explode your time-series database. Keep labels to model and cause at most, and push detailed traces to a sampling-based tracer.

Histogram of stream duration

Also record stream_duration_seconds as a histogram. A sudden increase in duration alongside stable drop rate can indicate provider degradation that has not yet crossed the drop threshold.

Separate client aborts from upstream failures

A browser navigating away triggers a TCP FIN from the client. That is not a provider reliability problem, but it still lowers your raw completion rate. Distinguish by logging who closed the socket.

With nginx proxying to an OpenAI-compatible backend, the $upstream_response_time and $request_completion variables help:

log_format stream '$time_local $status $upstream_status '
                  '$upstream_response_time $request_time '
                  '$connection $request_completion';

If $request_completion is “ABORTED” but $upstream_status is 200, the client left early. If $upstream_status is 502 and $upstream_response_time is short, the provider dropped the connection.

Counting these separately keeps your streaming completion rates dropped connections dashboard honest.

Apply fallback and retry carefully

Retrying a dropped stream is not like retrying a POST. If you resend the same prompt, the model generates from scratch, wasting tokens and latency. Better: on upstream drop before finish_reason, reconnect and append the last K tokens you received as context, or use a gateway that supports resume.

An OpenAI-compatible endpoint that addresses 240+ models with automatic fallback when a provider is rate-limited or degraded can mask some drops—but only if it forwards the partial stream state or restarts transparently. Verify that the fallback path does not silently duplicate tokens.

async def resilient_stream(messages, model, max_retries=2):
    last_tokens = []
    for attempt in range(max_retries):
        try:
            async for chunk in stream_with_metrics(messages, model):
                last_tokens.append(chunk)  # simplified
                yield chunk
            return
        except UpstreamDrop:
            messages = messages + [{"role": "assistant", "content": "".join(last_tokens)}]
            continue

Tradeoff: structured output

Resuming via prompt append breaks if the model already emitted a tool call or structured output. For JSON mode, you often must restart entirely and validate the full object. Factor that into retry logic: if finish_reason is missing and you are in structured mode, full restart is safer.

Set SLOs and alert on drops

Define an error budget: e.g., 99% of started streams for a given model complete within 10s of the expected length. Alert when the drop rate exceeds 1% over 5 minutes for production models.

alert: HighStreamDropRate
expr: |
  sum(rate(streams_dropped_total{cause="upstream"}[5m]))
  / sum(rate(streams_started_total[5m])) > 0.01
for: 5m
labels:
  severity: page

Pitfall: absolute counts

Common pitfall: alerting on absolute drop counts during traffic spikes. A 50-drop spike at 2% of 2500 streams is different from 50 drops at 20% of 250 streams. Always use ratios.

Pitfalls and tradeoffs summary

  • Chunk timeouts vs drops: A slow stream that eventually finishes is not a drop. Use a per-chunk idle timeout (e.g., 30s) to classify stalls as drops only after the timeout fires.
  • SSE vs WebSocket: Server-Sent Events over HTTP/1.1 is the default for OpenAI-compatible APIs. WebSocket gives you bidirectional control to detect client cancel faster, but adds complexity.
  • Debug storage cost: Saving full stream payloads for every drop is expensive. Sample 5% of drops and store truncated prefixes.
  • SDK buffering: Some client SDKs buffer chunks internally; if you measure at the SDK boundary you may miss network-level resets that occur between buffer flushes.
  • Metering mismatch: Per-token usage reported by the provider may count tokens sent before a drop. Reconcile billed tokens against completed streams to catch silent truncation that the provider still charged for.

Tracking streaming completion rates dropped connections is not a one-time task. Wire these metrics into your deploy pipeline so a new model version or provider change shows up as a movement in the completion ratio before users complain.

Tagsstreamingreliabilityperformance-monitoringlatency

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All latency & streaming performance monitoring posts →