Free lesson · GenAI Application Engineering
Build a streaming SSE test harness across 4 LLM providers
Build a StreamingTestClient class wrapping httpx.AsyncClient to test FastAPI streaming endpoints via Server-Sent Events. Implement parse_sse_stream() that consumes async byte iterators and yields ParsedSSEEvent objects with event type, data payload, and timing metadata. Create three assertion helpers: assert_stream_completes(timeout_seconds) verifying the stream terminates with a [DONE] sentinel, assert_token_count_within(min_tokens, max_tokens) validating output length, and assert_no_stream_errors() checking for mid-stream error events. Write a provider_matrix pytest fixture using @pytest.mark.parametrize that runs each test against /v1/chat/stream for OpenAI GPT-4o, Gemini 2.5 Flash, Anthropic Claude, and Llama 4. Build test_streaming_latency() using pytest-asyncio to measure time-to-first-token and inter-token latency p95. Validate responses with StreamChunkModel Pydantic schema.
Course: Full-Stack GenAI Applications · Chapter 15 · Testing & Evaluation for GenAI APIs
Free to read — no subscription required.
Introduction
When you ship a streaming LLM endpoint, the bugs that surface in production are rarely the ones a JSON-shaped integration test catches. Half a frame arrives, the provider drops the connection before sending [DONE], or an Anthropic stream ends with message_stop while your parser still waits for OpenAI's terminator — and every silent failure reaches users as a hung browser tab or a truncated answer. By the end of this lesson you'll be able to design a provider-agnostic SSE test harness that validates token delivery, error propagation, and stream termination across OpenAI, Anthropic, Gemini, and Llama 4, and wires the results into a CI gate.
Key Terminology
- TTFT (Time to First Token) — elapsed time from request dispatch to the first chunk carrying non-empty content. It is the primary user-perceived latency metric your harness must measure on every streaming run, because a stream that connects but stalls is the failure mode REST tests cannot see.
- Backpressure — the condition where the consumer (your test client) reads slower than the producer (the LLM provider) sends, causing buffer accumulation that can trigger timeouts or dropped frames. Your harness needs to reproduce it deliberately so the endpoint's flow-control behavior is exercised, not just observed in production incidents.
- SSE Sentinel — the protocol-level signal indicating stream completion, such as
data: [DONE](OpenAI / Llama 4) orevent: message_stop(Anthropic). Each provider terminates differently, so the harness must assert on the right sentinel per provider to catch silently-truncated streams.
Concepts
Error Injection and Edge-Case Coverage
Beyond the happy path, a robust harness must validate how your streaming endpoint behaves under failure conditions. Three categories of errors demand explicit test coverage:
- Mid-stream provider failures: The upstream LLM returns an HTTP 200 and begins streaming, then sends an SSE frame containing an error object (e.g., OpenAI's
{"error": {"message": "rate limit"}}) instead of a content delta. Your endpoint must propagate this as a structured error chunk, not silently drop it. - Connection resets: The provider closes the TCP socket before sending [DONE]. Your FastAPI endpoint must detect the incomplete stream and surface an appropriate error to the client rather than returning a truncated response that appears successful.
- Malformed SSE frames: Partial JSON payloads, missing
data:prefixes, or binary garbage injected into the stream. The parse_sse_stream function's json.JSONDecodeError handler covers this, but your test must verify the handler fires and the chunk is still yielded with the _raw fallback.
To test these scenarios without hitting live providers, inject failures at the ASGI transport layer using a mock that yields crafted byte sequences. For example, a mock that sends three valid chunks followed by a connection close (no [DONE]) validates your incomplete-stream detection logic. Pair this with pytest.raises or assertion on a stream_error field in your response model.
Operational Integration with CI/CD
Streaming tests inherently depend on timing, which makes them flaky candidates for CI pipelines. Apply these hardening strategies:
For CI integration, run streaming tests against deterministic mock providers that return canned SSE sequences with fixed timing. Reserve live-provider streaming tests for a nightly or pre-release gate where flakiness is tolerable and results feed into Promptfoo evaluation dashboards for trend analysis. Tag streaming tests with @pytest.mark.streaming so teams can include or exclude them from fast feedback loops. Store TTFT percentiles (p50, p95, p99) as CI artifacts and alert when the p95 regresses beyond a configurable threshold—this catches infrastructure degradation (DNS resolution delays, TLS handshake changes, container resource limits) before it reaches users.
The streaming test harness you have built here becomes the foundation for the evaluation layers covered in the remaining goals: Promptfoo pipelines consume these same endpoints to run assertion-based prompt regression tests, RAGAS evaluation suites verify the factual quality of the streamed content, DeepEval benchmarks compare cross-provider output quality, and security red-teaming campaigns fuzz these endpoints with adversarial inputs designed to break the SSE parser or bypass content guardrails. Each subsequent layer builds on the confidence that the transport itself is reliable.
Code Walkthrough
Building on the error-injection categories and CI/CD integration patterns from the previous section, the harness comes together in three layers, each backed by code you can run against a FastAPI streaming endpoint today. First, you map the SSE contract onto each provider so the parser knows which frames mean "token", which mean "done", and which mean "error". Second, you build a StreamingTestClient that wraps httpx.AsyncClient and a parse_sse_stream() coroutine that yields uniform ParsedChunk dataclasses regardless of upstream framing. Third, you layer provider-aware assertions and fault-injection mocks on top so a single pytest invocation validates OpenAI, Anthropic, Gemini, and Llama 4/vLLM streams — including mid-stream errors and premature disconnects — without burning live tokens.
The SSE Contract and Why It Breaks Differently Per Provider
Before writing a single test, you must internalize what the SSE specification actually guarantees—and where each LLM provider deviates. The SSE protocol (defined in the W3C EventSource spec) transmits UTF-8 text frames separated by double newlines (\n\n), each containing optional event:, data:, id:, and retry: fields. The data: field carries the payload. A stream terminates when the server sends data: [DONE] (OpenAI convention) or simply closes the TCP connection.
Here is where provider divergence creates real bugs:
- OpenAI sends data: {"choices": [{"delta": {"content": "token"}}]} with a final data: [DONE] sentinel. Empty delta objects appear for role-only chunks at stream start.
- Anthropic uses a typed event system:
event: content_block_deltawithdata: {"type": "content_block_delta", "delta": {"text": "token"}}. The stream ends withevent: message_stop. There is no [DONE] sentinel. - Gemini streams JSON objects where each chunk contains candidates[0].content.parts[0].text. The stream termination signal is an empty response or connection close—no explicit sentinel.
- Llama 4 (served via vLLM or TGI) follows the OpenAI-compatible format when using the
/v1/chat/completionsendpoint, but raw inference endpointsreturnnewline-delimited JSON (NDJSON), not SSE.
These differences mean a single parse_sse_stream() function must handle variable framing, and your assertions must be provider-aware. The following diagram illustrates the test harness architecture that abstracts these concerns:
StreamingTestClient fires POST requests against a FastAPI streaming endpoint, capturing raw SSE byte streams that parse_sse_stream converts into typed ParsedChunk objects. A Provider Router then dispatches each chunk to provider-specific assertion modules—OpenAI, Anthropic, Gemini, and Llama 4/vLLM—before rolling results into a Unified Test Verdict. This architecture validates that every LLM provider's streaming contract holds before the CI/CD Pipeline Gate promotes a build, catching format regressions across four distinct SSE schemas in a single test pass.
Code snippet mermaid
Loading diagram...
- Line 1: Declares a Mermaid flowchart with top-down (TD) layout direction.
- Line 2: Defines node A ("StreamingTestClient") sending a
POST /v1/chat/completionsrequest to node B ("FastAPI Streaming Endpoint"), representing the test client initiating an SSE streaming call. - Line 3: Shows node B returning a Server-Sent Events byte stream to node
C("parse_sse_stream"), the function that deserializes the raw SSE data. - Line 4: Shows parse_sse_stream yielding typed ParsedChunk objects into node D, a decision diamond labeled "Provider Router" that branches based on the LLM provider format.
- Lines 5-8: Define four parallel edges from the Provider Router to provider-specific assertion nodes:
Efor OpenAI format,Ffor Anthropic format,Gfor Gemini format, andHfor Llama 4 / vLLM format, each applying provider-specific validation rules to the parsed chunks. - Lines 9-12: Converge all four assertion nodes (
E,F,G,H) into a single nodeI("Unified Test Verdict"), aggregating pass/fail results from every provider into one combined outcome. - Line 13: Connects the Unified Test Verdict to node J ("CI/CD Pipeline Gate"), forwarding metrics and failure details to determine whether the pipeline build passes or is blocked.
This architecture separates transport parsing (the parse_sse_stream layer) from semantic validation (provider-specific assertion sets), allowing you to add new providers without rewriting core test infrastructure.
Building the StreamingTestClient and SSE Parser
The foundation of the harness is a StreamingTestClient class that wraps httpx.AsyncClient to issue streaming requests against your FastAPI application and a parse_sse_stream() coroutine generator that consumes raw async byte iterators, reassembles SSE frames, and yields structured ParsedChunk dataclass instances. The client must handle connection-level timeouts separately from per-chunk read timeouts—a critical distinction because a stream that starts successfully but stalls mid-generation should fail differently than one that never connects. The ParsedChunk dataclass normalizes the heterogeneous provider payloads into a uniform shape with event_type, content, raw_data, is_done, and latency_ms fields, enabling downstream assertions to operate provider-agnostically where possible.
Code snippet python
1import httpx 2import json 3import time 4from dataclasses import dataclass, field 5from typing import AsyncIterator, Optional 6 7@dataclass 8class ParsedChunk: 9 event_type: str = "message" 10 content: str = "" 11 raw_data: dict = field(default_factory=dict) 12 is_done: bool = False 13 latency_ms: float = 0.0 14 15async def parse_sse_stream( 16 byte_stream: AsyncIterator[bytes], 17 provider: str = "openai", 18) -> AsyncIterator[ParsedChunk]: 19 buffer = "" 20 current_event = "message" 21 stream_start = time.perf_counter() 22 23 async for raw_bytes in byte_stream: 24 buffer += raw_bytes.decode("utf-8", errors="replace") 25 while "\n\n" in buffer: 26 frame, buffer = buffer.split("\n\n", 1) 27 chunk = ParsedChunk( 28 latency_ms=(time.perf_counter() - stream_start) * 1000 29 ) 30 for line in frame.strip().split("\n"): 31 if line.startswith("event:"): 32 current_event = line[len("event:"):].strip() 33 chunk.event_type = current_event 34 elif line.startswith("data:"): 35 data_str = line[len("data:"):].strip() 36 if data_str == "[DONE]": 37 chunk.is_done = True 38 yield chunk 39 return 40 try: 41 chunk.raw_data = json.loads(data_str) 42 except json.JSONDecodeError: 43 chunk.raw_data = {"_raw": data_str} 44 45 if not chunk.is_done and chunk.raw_data: 46 chunk.content = _extract_content(chunk.raw_data, provider) 47 if current_event == "message_stop": 48 chunk.is_done = True 49 yield chunk 50 51def _extract_content(data: dict, provider: str) -> str: 52 if provider in ("openai", "llama4"): 53 delta = data.get("choices", [{}])[0].get("delta", {}) 54 return delta.get("content", "") 55 elif provider == "anthropic": 56 return data.get("delta", {}).get("text", "") 57 elif provider == "gemini": 58 parts = data.get("candidates", [{}])[0].get("content", {}).get("parts", []) 59 return parts[0].get("text", "") if parts else "" 60 return ""
- Lines 1-4: Imports
httpxforasyncHTTP,jsonfor SSE payload parsing,timefor latency measurement, anddataclassutilities for the structured chunk type. - Lines 6-11: The
ParsedChunkdataclass defines the normalized output contract. Every SSE frame becomes one of these, regardless of provider. Theis_donefield defaults to False and flips to True only when a terminal signal is detected. - Lines 13-16:
parse_sse_streamaccepts anasyncbyte iterator (whathttpxreturns during streaming) and aproviderstring that controls content extraction logic. It returns anasyncgenerator ofParsedChunkobjects. - Lines 17-19: A string
bufferaccumulates raw bytes because TCP delivery does not guarantee SSE frame alignment—a singlerecv()call might contain half a frame or three frames. - Lines 21-23: The outer loop reads raw bytes and appends decoded UTF-8 to the buffer. The
errors="replace"parameter prevents crashes on malformed multi-byte sequences mid-chunk. - Lines 24-25: The inner
whileloop splits on\n\n(the SSE frame delimiter). Each split produces one complete frame for parsing while leftover bytes stay in the buffer. - Lines 26-28: A fresh
ParsedChunkis created per frame with the current latency calculated from stream start. - Lines 29-33: Each line within a frame is inspected: lines starting with
event:set the event type (critical for Anthropic's typed event system); lines starting withdata:carry the payload. - Lines 34-36: The
[DONE]sentinel (used by OpenAI and Llama 4) triggers immediateyieldand generatorreturn—no further frames are processed. - Lines 37-40: JSON parsing is attempted on the data payload. If the provider sends malformed JSON (a real production scenario during overload), the raw string is preserved under a
_rawkey rather than crashing the entire test. - Lines 42-46: For non-terminal frames with valid data, content is extracted via the provider-specific
_extract_contenthelper, and the Anthropicmessage_stopevent type is detected as a done signal. - Lines 48-57:
_extract_contentis a pure function that navigates the different JSON nesting structures for each provider. OpenAI and Llama 4 share thechoices[0].delta.contentpath, Anthropic usesdelta.text, and Gemini nests undercandidates[0].content.parts[0].text. Returning an empty string for unknown providers ensures graceful degradation rather than a KeyError crash.
Writing Provider-Parameterized Async Tests
With the parser in place, the next layer is a pytest-based async test suite using pytest.mark.parametrize to run identical test logic across all four providers. The test below validates three critical streaming properties: that at least one non-empty content chunk arrives, that the concatenated output forms coherent text (not garbled bytes), and that the stream terminates with an explicit done signal within a configurable timeout. The StreamingTestClient class wraps httpx.AsyncClient with the application's ASGI transport so tests execute in-process without needing a running server, while anyio provides the async test backend.
Code snippet python
1import pytest 2import httpx 3from httpx import ASGITransport 4from app.main import app # your FastAPI application 5 6class StreamingTestClient: 7 def __init__(self, timeout: float = 30.0): 8 transport = ASGITransport(app=app) 9 self.client = httpx.AsyncClient(transport=transport, base_url="http://test") 10 self.timeout = httpx.Timeout(timeout, connect=5.0) 11 12 async def stream_completion(self, provider: str, prompt: str): 13 payload = {"model": f"{provider}/default", "messages": [{"role": "user", "content": prompt}], "stream": True} 14 async with self.client.stream("POST", "/v1/chat/completions", json=payload, timeout=self.timeout) as resp: 15 assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" 16 chunks = [] 17 async for chunk in parse_sse_stream(resp.aiter_bytes(), provider): 18 chunks.append(chunk) 19 return chunks 20 21PROVIDERS = ["openai", "anthropic", "gemini", "llama4"] 22 23@pytest.mark.anyio 24@pytest.mark.parametrize("provider", PROVIDERS) 25async def test_stream_delivers_content(provider: str): 26 client = StreamingTestClient(timeout=45.0) 27 chunks = await client.stream_completion(provider, "Say hello in one sentence.") 28 content_chunks = [c for c in chunks if c.content] 29 assert len(content_chunks) > 0, f"{provider}: no content chunks received" 30 full_text = "".join(c.content for c in content_chunks) 31 assert len(full_text) > 5, f"{provider}: response too short: {full_text!r}" 32 assert chunks[-1].is_done is True, f"{provider}: stream did not terminate cleanly" 33 34@pytest.mark.anyio 35@pytest.mark.parametrize("provider", PROVIDERS) 36async def test_first_chunk_latency(provider: str): 37 client = StreamingTestClient(timeout=45.0) 38 chunks = await client.stream_completion(provider, "Say hello.") 39 first_content = next((c for c in chunks if c.content), None) 40 assert first_content is not None, f"{provider}: no content chunk found" 41 assert first_content.latency_ms < 3000, ( 42 f"{provider}: TTFT {first_content.latency_ms:.0f}ms exceeds 3s threshold" 43 )
- Lines 1-4: Test dependencies are imported—
httpxfor theasyncclient,ASGITransportfor in-process ASGI testing without spawning a server, and the FastAPIappinstance from your application module. - Lines 6-10:
StreamingTestClient.__init__creates anhttpx.AsyncClientbound to the ASGI transport. TheTimeoutobject separates connection timeout (5 seconds) from overall read timeout, which prevents slow provider responses from hanging CI indefinitely. - Lines 12-19:
stream_completionconstructs a standard chat completion payload withstreamset to True, opens a streaming POST request, asserts the HTTP status is 200, and collects all parsed chunks into a list for assertion. - Line 21: The
PROVIDERSlist serves as the parametrize source. Adding a fifth provider later requires only appending to this list. - Lines 23-33:
test_stream_delivers_contentvalidates three invariants per provider: at least one chunk carries non-emptycontent, the assembled text exceeds a minimum length (catching edge cases where providersreturnonly whitespace), and the final chunk hasis_doneset to True. - Lines 35-44:
test_first_chunk_latencymeasures time-to-first-token (TTFT) by inspecting thelatency_msof the first chunk that contains content. The 3-second threshold is aggressive for production but appropriate for CI, where provider round-trip time should be predictable behind a mock or staging endpoint. Usingnext()with a generator expression returns None if no content chunk exists, which the subsequent assertion catches cleanly.
Do's and Don'ts
Do's
- ✓Do normalize heterogeneous provider payloads into a uniform
ParsedChunkdataclass insideparse_sse_stream()— keeping transport parsing separate from provider-specific assertion modules means you can add a fifth provider without rewriting OpenAI, Anthropic, or Gemini assertion logic, and a singlepytestrun covers all four formats in one pass. - ✓Do configure connection-level and per-chunk read timeouts as distinct parameters in
StreamingTestClient— a stream that opens a TCP connection and then stalls mid-generation is a different failure mode from one that never connects, and collapsing both into a single timeout causes the harness to misclassify hung streams as connectivity errors. - ✓Do write provider-specific termination assertions behind the Provider Router for each of the four distinct stream-end signals — OpenAI's
data: [DONE]sentinel, Anthropic'sevent: message_stop, Gemini's connection-close, and Llama 4/vLLM's NDJSON format each require separate checks, so a router that dispatchesParsedChunkobjects to the right assertion module catches format regressions a single generic validator will silently miss.
Don'ts
- ✗Don't assume OpenAI's
data: [DONE]sentinel applies to Anthropic or Gemini streams — Anthropic ends withevent: message_stopand emits no[DONE]frame, while Gemini signals termination via connection close; a parser that waits for[DONE]will hang indefinitely on both providers, reproducing exactly the hung browser-tab failure the harness is designed to catch. - ✗Don't point the
StreamingTestClientat Llama 4's raw inference endpoint expecting SSE frames — raw vLLM and TGI inference routes return NDJSON (newline-delimited JSON), not SSE; only the/v1/chat/completionsOpenAI-compatible endpoint sends SSE, soparse_sse_stream()must not be applied to the raw route or it will yield zeroParsedChunkobjects and pass vacuously. - ✗Don't use live API calls to exercise mid-stream error paths like premature disconnects or dropped connections — fault-injection mocks let the CI gate validate that
StreamingTestClientpropagates those errors correctly across all four providers without burning tokens, hitting rate limits, or making test results non-deterministic on network variance.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in Full-Stack GenAI Applications
- Ch 13Build an agentic RAG agent with Pydantic AI
- Ch 13Evaluate RAG quality with RAGAS metrics
- Ch 14Build a semantic cache with Redis + embedding similarity
- Ch 15Build a streaming SSE test harness across 4 LLM providersYou are here
- Ch 16Instrument LiteLLM calls with Langfuse traces
- Ch 16Build OpenTelemetry distributed trace pipelines
- Ch 16Manage prompt template versions with Langfuse