Skip to main content
Lesson 6 of 16 · Chat Completions: Mastering the API

Implement streaming in Python

Lesson 6 of 16 Updated November 2, 2026 Developer
On this page
  1. From prototype to production
  2. The basic pattern
  3. Comprehensive error handling
  4. Async streaming with asyncio
  5. Integration with FastAPI
  6. Measuring performance
  7. Key takeaways

From prototype to production

The previous lesson covered the concepts of SSE streaming. The harder part remains: turning a demo loop into a client that holds up under load, survives network errors, and feeds a web interface. This lesson follows the real trajectory of a project—skeleton first, then error handling, then async, then HTTP exposure, and finally measurement.

The basic pattern

Everything starts with this ten-line skeleton, which you will adapt for every project. It does two things at once: displays tokens as they arrive and accumulates them for the function return.

from mistralai.client import Mistral
import os

client = Mistral(api_key=os.getenv("MISTRAL_API_KEY"))

def stream_chat(messages: list, model: str = "mistral-large-latest") -> str:
    """Sends a streaming request and displays tokens in real time."""
    stream = client.chat.stream(
        model=model,
        messages=messages
    )

    full_text = ""
    for chunk in stream:
        content = chunk.data.choices[0].delta.content
        if content:
            full_text += content
            print(content, end="", flush=True)

    print()  # New line
    return full_text

This version works for a local script. It has a fatal flaw in production: it prints to the terminal, so it can do nothing but terminal work.

Comprehensive error handling

Network errors, timeouts, and rate limits are not hypotheticals: they are certainties at real traffic scale. The client below addresses both problems. It automatically retries 429 errors with exponential backoff, and it no longer prints anything: it emits typed events via yield, leaving it to the caller to decide what to do with them—display them, push them to a WebSocket, or write them to a database.

from mistralai.client import Mistral
from mistralai.client.errors import SDKError
import os
import time

client = Mistral(api_key=os.getenv("MISTRAL_API_KEY"))

def stream_with_retry(
    messages: list,
    model: str = "mistral-large-latest",
    max_retries: int = 3,
    max_tokens: int = 2000
) -> dict:
    """Streaming with automatic retry and metrics."""
    for attempt in range(max_retries):
        try:
            stream = client.chat.stream(
                model=model,
                messages=messages,
                max_tokens=max_tokens
            )

            full_text = ""
            finish_reason = None
            chunk_count = 0

            for chunk in stream:
                delta = chunk.data.choices[0].delta
                if delta.content:
                    full_text += delta.content
                    chunk_count += 1
                    yield {"type": "token", "content": delta.content}

                if chunk.data.choices[0].finish_reason:
                    finish_reason = chunk.data.choices[0].finish_reason

            yield {
                "type": "done",
                "full_text": full_text,
                "finish_reason": finish_reason,
                "chunks": chunk_count
            }
            return

        except SDKError as e:
            if e.status_code == 429 and attempt < max_retries - 1:
                wait_time = 2 ** attempt  # Exponential backoff
                yield {"type": "retry", "wait": wait_time, "attempt": attempt + 1}
                time.sleep(wait_time)
            else:
                yield {"type": "error", "message": str(e), "status": e.status_code}
                return

        except Exception as e:
            yield {"type": "error", "message": str(e), "status": None}
            return

The exponential backoff 2 ** attempt waits 1 second, then 2, then 4. This progression is not decorative: if all your clients retried at the same fixed rhythm, they would hit the API in synchronized bursts and prolong the very saturation they are suffering. Note also that only error 429 triggers a retry—retrying a 401 due to an invalid key would only delay diagnosis.

On the caller side, consumption becomes a simple event read:

messages = [
    {"role": "system", "content": "You are a technical assistant."},
    {"role": "user", "content": "Explain design patterns in Python."}
]

for event in stream_with_retry(messages):
    if event["type"] == "token":
        print(event["content"], end="", flush=True)
    elif event["type"] == "retry":
        print(f"\n[Retry {event['attempt']}, waiting {event['wait']}s...]")
    elif event["type"] == "done":
        print(f"\n\n--- Done ({event['chunks']} chunks, {event['finish_reason']})")
    elif event["type"] == "error":
        print(f"\n[Error: {event['message']}]")

Async streaming with asyncio

The synchronous client blocks its thread for the entire generation. On a web server that must handle fifty simultaneous conversations, this means immobilizing fifty threads just to wait. The async client lifts this constraint:

from mistralai.client import Mistral
import asyncio
import os

client = Mistral(api_key=os.getenv("MISTRAL_API_KEY"))

async def async_stream_chat(messages: list) -> str:
    """Async streaming for concurrent applications."""
    stream = await client.chat.stream_async(
        model="mistral-large-latest",
        messages=messages
    )

    full_text = ""
    async for chunk in stream:
        content = chunk.data.choices[0].delta.content
        if content:
            full_text += content
            print(content, end="", flush=True)

    print()
    return full_text

# Execution
async def main():
    messages = [{"role": "user", "content": "Hello, introduce yourself."}]
    result = await async_stream_chat(messages)
    print(f"\nTotal: {len(result)} characters")

asyncio.run(main())

The logic is identical to the synchronous version; only stream_async, the await on the call, and the async for on the loop change.

Integration with FastAPI

Next, you need to get this stream out of your Python process and into the browser. FastAPI handles this with StreamingResponse, provided you reformat each fragment to the SSE format seen in the previous lesson:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from mistralai.client import Mistral
import os
import json

app = FastAPI()
client = Mistral(api_key=os.getenv("MISTRAL_API_KEY"))

async def generate_stream(prompt: str):
    """SSE generator for FastAPI."""
    stream = await client.chat.stream_async(
        model="mistral-large-latest",
        messages=[{"role": "user", "content": prompt}]
    )

    async for chunk in stream:
        content = chunk.data.choices[0].delta.content
        if content:
            data = json.dumps({"content": content})
            yield f"data: {data}\n\n"

    yield "data: [DONE]\n\n"

@app.get("/chat/stream")
async def chat_stream(prompt: str):
    return StreamingResponse(
        generate_stream(prompt),
        media_type="text/event-stream"
    )

Two details determine whether this endpoint works correctly. The double newline \n\n that ends each yield is what separates events in the SSE protocol: without it, the browser waits indefinitely for an event it considers unfinished. And media_type="text/event-stream" is what allows the JavaScript client to consume the response with an EventSource rather than a standard fetch.

Measuring performance

Once the stream is in place, you will want to know how it performs. Two metrics are enough to drive optimization:

import time

start = time.perf_counter()
first_token_time = None
token_count = 0

stream = client.chat.stream(
    model="mistral-large-latest",
    messages=[{"role": "user", "content": "Write a paragraph about Paris."}]
)

for chunk in stream:
    if chunk.data.choices[0].delta.content:
        if first_token_time is None:
            first_token_time = time.perf_counter() - start
        token_count += 1

total_time = time.perf_counter() - start

print(f"Time to First Token (TTFT): {first_token_time:.3f}s")
print(f"Tokens generated: {token_count}")
print(f"Total time: {total_time:.3f}s")
print(f"Tokens/second: {token_count / total_time:.1f}")

TTFT (Time to First Token)—the time between sending the request and displaying the first character—is the most important metric for user experience. It takes precedence over total time: a fifteen-second generation whose first word appears in three-tenths of a second will feel fast, while a five-second generation delivered in one block will feel slow. Measure it on your real prompts, system message included—a two-page system message lengthens TTFT before the first token is even generated.

Key takeaways

  • Use a generator pattern (yield) to propagate streaming tokens
  • Implement exponential backoff for 429 errors (rate limit)
  • Prefer stream_async for concurrent web applications
  • Measure TTFT and tokens/second to optimize performance
  • With FastAPI, StreamingResponse + text/event-stream propagates SSE to the browser