The Failure Symptom
Production alert: SSE endpoint serving LangChain chat completions starts timing out at 500 concurrent connections. No exceptions logged. CPU idle at 12%. Memory normal. Clients just hang on EventSource connection, then 504 after 60s.
# Nginx access log
"POST /v1/chat/stream HTTP/1.1" 504 0 60.001
"POST /v1/chat/stream HTTP/1.1" 504 0 60.004
Strace shows worker processes stuck in futex() wait. All 8 uvicorn workers frozen.
The Red Herring
LangChain docs show this standard async streaming pattern:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
app = FastAPI()
@app.post("/v1/chat/stream")
async def stream_chat(prompt: str):
llm = ChatOpenAI(
base_url="https://b-lost.com/v1",
streaming=True
)
async def generate():
async for chunk in llm.astream([HumanMessage(content=prompt)]):
yield f"data: {chunk.content}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
Looks correct. Runs fine in dev at 10 req/s. Tutorial says "just use astream() for async".
Under load testing with 500 parallel connections, requests start blocking after ~200 complete. Adding more uvicorn workers makes it worse.
The Root Cause
LangChain's ChatOpenAI client instantiates a new httpx.AsyncClient on every request by default, but doesn't explicitly close it when streaming completes. Under high concurrency:
- Each SSE connection holds an unclosed
httpx.AsyncClient - Default
httpxconnection pool limit is 100 connections - Request 101+ blocks waiting for a free connection slot in the pool
- Because clients never close, pool never releases slots
- Deadlock: all workers waiting on connection pool sempahore
The fix: explicit client lifecycle management with connection pooling:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
import httpx
# Shared httpx client with explicit limits
http_client = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global http_client
http_client = httpx.AsyncClient(
limits=httpx.Limits(max_connections=500, max_keepalive_connections=100),
timeout=httpx.Timeout(60.0)
)
yield
await http_client.aclose()
app = FastAPI(lifespan=lifespan)
@app.post("/v1/chat/stream")
async def stream_chat(prompt: str):
llm = ChatOpenAI(
base_url="https://b-lost.com/v1",
streaming=True,
http_client=http_client # Reuse shared client
)
async def generate():
async for chunk in llm.astream([HumanMessage(content=prompt)]):
yield f"data: {chunk.content}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
After deploy: sustained 2000 req/s, zero timeouts, connection pool utilization at 18%.
What We Missed
LangChain's async clients inherit httpx defaults designed for script usage (low concurrency, short lifecycle), not production server workloads. The library doesn't enforce or document connection pool management for FastAPI integration.
When benchmarking the fix through B-Lost's relay gateway under simulated production load (1000 concurrent SSE streams), observed connection pool exhaustion dropped from 47% error rate to 0%, with P99 latency improving from 8.2s to 340ms.
Invariant Rule
Never instantiate HTTP clients inside FastAPI route handlers. Always initialize shared clients in lifespan context with explicit connection limits matching your expected concurrency ceiling. Measure pool utilization under load before declaring victory.
Top comments (0)