Most LLM integrations bottleneck on synchronous I/O the moment you fan out to multiple prompts. Using a python httpx asyncclient llm api call pattern lets you issue dozens of concurrent requests against an OpenAI-compatible endpoint without threading headaches. This guide walks through a production-shaped setup: connection pooling, retries, streaming, and structured error handling.
Step 1: Install dependencies and configure the client
Install httpx with pip. Use a recent version (0.27+) that supports the async transport improvements and proper connection recycling.
pip install "httpx>=0.27"
Create exactly one AsyncClient per process and reuse it. The client manages a connection pool, TCP keep-alive, and DNS caching. Creating a new client per request will exhaust file descriptors under load.
import httpx
client = httpx.AsyncClient(
base_url="https://api.openai.com/v1",
headers={"Authorization": "Bearer $API_KEY"},
timeout=httpx.Timeout(30.0, connect=5.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
)
If you control the gateway, enable HTTP/2 by passing http2=True. Most LLM gateways accept HTTP/1.1 fine; HTTP/2 helps when you push past 100 concurrent streams. Always close the client on shutdown:
async def shutdown():
await client.aclose()
Step 2: Send a single chat completion
Define an async function that posts to /chat/completions. The python httpx asyncclient llm api request shape mirrors the OpenAI REST contract exactly, so any OpenAI-compatible endpoint works without SDK lock-in.
import asyncio
async def complete(prompt: str, model: str = "gpt-4o-mini") -> dict:
resp = await client.post(
"/chat/completions",
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.2,
},
)
resp.raise_for_status()
return resp.json()
async def main():
data = await complete("Summarize: async I/O matters.")
print(data["choices"][0]["message"]["content"])
asyncio.run(main())
Verify success
Run the script. A generated string prints to stdout. Internally, check resp.status_code == 200 and that data["choices"] is a non-empty list. A 401 means the bearer token is missing or malformed. A 429 means you hit a rate limit and should back off (see Step 4).
Step 3: Fan out concurrent requests
The real value of async is cheap concurrency. Use asyncio.gather to batch prompts. The python httpx asyncclient llm api client pools connections, so 50 parallel calls add little overhead beyond socket memory.
async def batch(prompts: list[str]) -> list[object]:
tasks = [complete(p) for p in prompts]
return await asyncio.gather(*tasks, return_exceptions=True)
async def main():
prompts = [f"Translate to French: {i}" for i in range(20)]
results = await batch(prompts)
ok = [r for r in results if isinstance(r, dict)]
print(f"{len(ok)}/{len(prompts)} succeeded")
for r in results:
if isinstance(r, Exception):
print("failed:", type(r).__name__)
asyncio.run(main())
return_exceptions=True prevents one bad response from killing the whole batch. In production, route exceptions to a dead-letter queue or retry bucket instead of printing.
Step 4: Retries, timeouts, and fallback
Networks flap. Providers return 503. Wrap the call in a bounded retry loop with exponential backoff. httpx raises httpx.TimeoutException on slow connections and httpx.HTTPStatusError on 4xx/5xx when you call raise_for_status().
async def complete_retry(prompt: str, attempts: int = 3) -> dict:
for i in range(attempts):
try:
return await complete(prompt)
except (httpx.TimeoutException, httpx.HTTPStatusError) as e:
if i == attempts - 1:
raise
await asyncio.sleep(2 ** i)
A gateway such as n4n.ai provides automatic fallback when a provider is rate-limited or degraded, but your client still owns timeout budgets and retry cadence. Do not assume the server will mask a dead TCP connection—local timeouts are mandatory.
Step 5: Stream tokens
For chat UIs, block on a full response is unacceptable. Use client.stream with stream: True in the payload. The server returns Server-Sent Events. Parse each data: line as JSON.
import json
async def stream_complete(prompt: str):
async with client.stream(
"POST",
"/chat/completions",
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": prompt}],
"stream": True,
},
) as resp:
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
yield json.loads(payload)["choices"][0]["delta"].get("content", "")
async def main():
async for tok in stream_complete("Tell me a joke"):
print(tok, end="", flush=True)
asyncio.run(main())
You get incremental strings instead of one multi-second wait. Always handle the [DONE] sentinel and JSON decode errors defensively.
Step 6: Pass routing and cache hints
Multi-provider gateways accept headers to pin a backend or enable prompt caching. n4n.ai honors client routing directives and forwards provider cache-control hints, so set them explicitly when you need determinism or cost control.
headers = {
"X-Route-To": "provider:anthropic",
"Cache-Control": "max-age=3600",
}
resp = await client.post(
"/chat/completions",
json={
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Long static context..."}],
},
headers=headers,
)
Against a raw provider these headers are ignored but harmless. In a routed setup they cut latency and token cost by reusing cached prefix computations.
Step 7: Measure usage and verify metering
OpenAI-compatible responses embed a usage object. Read it per call to track token spend.
data = await complete("Explain asyncio")
usage = data["usage"]
print("prompt", usage["prompt_tokens"], "completion", usage["completion_tokens"])
When you route through a gateway with per-token usage metering, the same usage block reflects the actual billed counts. Log it alongside a request ID:
import logging
logging.info("req=%s prompt=%d completion=%d",
data["id"], usage["prompt_tokens"], usage["completion_tokens"])
End-to-end verification
Run the Step 3 batch with five prompts. Confirm:
- Status codes are 200 or exceptions caught and logged.
- Streaming (Step 5) yields multiple non-empty chunks.
- Usage prints non-zero token counts.
That proves the python httpx asyncclient llm api pipeline works from single call to concurrent fan-out.
Step 8: Bound concurrency with a worker pool
asyncio.gather fires all tasks at once. If your provider quota is strict, wrap calls in a semaphore or queue.
sem = asyncio.Semaphore(10)
async def bounded_complete(p: str) -> dict:
async with sem:
return await complete_retry(p)
async def bounded_batch(prompts: list[str]) -> list[object]:
return await asyncio.gather(
*(bounded_complete(p) for p in prompts),
return_exceptions=True,
)
This caps simultaneous connections at 10 regardless of list size. Combine with the retry logic from Step 4 and the streaming generator from Step 5 for a complete client module.
Keep the AsyncClient at module scope, never per request. Use async with for streams to guarantee socket release. The pattern above holds up under sustained load and keeps your LLM calls cheap, observable, and free of thread pools.