When you wire Python services to language model endpoints, the choice between python httpx vs requests llm clients shapes your concurrency model, error handling, and codebase weight. Both libraries speak HTTP/1.1 and JSON, but they diverge sharply on async support and connection pooling.
Capabilities
requests is fundamentally a synchronous, blocking HTTP client. You open a Session, reuse TCP connections, and call .post(). It has no event loop, no async/await, and no native HTTP/2.
httpx ships both a sync API mirroring requests and a fully async client built on asyncio. It speaks HTTP/2 when you install the h2 extra, and it exposes fine-grained timeouts, connection limits, and mountable transports.
For a basic chat completion call, the sync versions look nearly identical:
# requests
import requests
resp = requests.post(
"https://api.openai.com/v1/chat/completions",
headers={"Authorization": "Bearer sk-..."},
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]},
timeout=30,
)
print(resp.json())
# httpx (sync)
import httpx
resp = httpx.post(
"https://api.openai.com/v1/chat/completions",
headers={"Authorization": "Bearer sk-..."},
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]},
timeout=30,
)
print(resp.json())
The async version changes the control flow but not the payload:
import httpx
import asyncio
async def call():
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://api.openai.com/v1/chat/completions",
headers={"Authorization": "Bearer sk-..."},
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]},
timeout=30,
)
return resp.json()
asyncio.run(call())
The real split in python httpx vs requests llm work appears when you need streaming tokens or concurrent fan-out to multiple models.
Streaming and Long Connections
LLM responses often arrive as server-sent tokens over a long-lived connection. With requests you block a thread while reading the stream:
with requests.post(url, json=payload, stream=True, timeout=60) as r:
for line in r.iter_lines():
if line:
print(line)
httpx provides the same sync pattern, plus an async iterator that never blocks the event loop:
async with httpx.AsyncClient() as client:
async with client.stream("POST", url, json=payload, timeout=60) as r:
async for line in r.aiter_lines():
if line:
print(line)
If your service streams completions to 500 websockets, the async path avoids 500 reserved threads.
Price and Cost Model
Neither library costs money; both are permissively licensed (Apache-2.0 for requests, BSD for httpx). The cost dimension is operational: infrastructure and engineering time.
requests blocks a thread per in-flight call. Under modest traffic you spin up multiple worker processes (Gunicorn pre-fork, Flask dev server) and pay RAM per process. That is simple and predictable.
httpx async lets a single process hold thousands of open connections on one event loop. If you run a gateway that fronts 240+ models and meters per-token usage, the async model shrinks your container count and CPU bill at high concurrency. The trade-off is cognitive overhead: async code demands careful avoidance of blocking calls inside the loop. For a batch script that runs nightly, the cheaper path is the one your team can maintain—usually requests.
Latency and Throughput
LLM inference is network-bound and latency-tolerant per token, but the aggregate tail latency kills sync designs. With requests, a worker handling a 10-second streaming response cannot serve another request without threads. A thread pool of 20 caps you at 20 concurrent generations.
httpx.AsyncClient multiplexes within one thread. You can issue 500 simultaneous completions and iterate their streams as data arrives:
async def fan_out(prompts):
async with httpx.AsyncClient(timeout=60) as client:
tasks = [client.post(url, json={"model": m, "messages": p}) for m, p in prompts]
return await asyncio.gather(*tasks)
The python httpx vs requests llm throughput gap is qualitative: thread context switching dominates at >100 concurrent LLM calls, while asyncio overhead stays flat. For low-QPS cron jobs, the difference is irrelevant.
Ergonomics
requests wins on immediate readability. A junior engineer can debug a failing POST in five minutes. Timeouts are a tuple (connect, read). Retries require urllib3.util.retry.Retry mounted on a HTTPAdapter:
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
s = requests.Session()
retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 503])
s.mount("https://", HTTPAdapter(max_retries=retry))
httpx mimics the requests surface but adds httpx.Timeout(connect=5, read=30, pool=5) and a transport argument for retries:
transport = httpx.HTTPTransport(retries=3)
client = httpx.Client(transport=transport)
Async ergonomics require async def, await, and an event loop runner. If your service already uses FastAPI or anyio, httpx drops in. If it is a plain Django management command, requests avoids async conversion pain.
Ecosystem and Library Support
requests is the default dependency for countless SDKs; if an LLM provider ships a Python wrapper, it likely wraps requests. Debugging proxies like mitmproxy and requests-toolbelt are mature.
httpx is adopted by the official OpenAI Python SDK for its async paths and by many newer agent frameworks. It supports HTTP/2, which can halve connection overhead to a gateway that supports it. Its ecosystem is younger but sufficient for production.
Limits and Edge Cases
Both clients fail the same ways: 429 rate limits, 503 degraded providers, and dead connections mid-stream. requests streaming uses resp.iter_lines(); you must wrap in try/except for ChunkedEncodingError. httpx uses async for chunk in resp.aiter_lines() and raises httpx.TransportError.
If you front calls with a gateway that provides automatic fallback when a provider is rate-limited or degraded, you still must handle the client side: set sensible timeouts and retry only idempotent GETs or safely retry POSTs with unique keys. n4n.ai exposes an OpenAI-compatible endpoint covering 240+ models and forwards provider cache-control hints; either library works, but httpx’s async retries scale better under fan-out.
Connection pooling limits matter: requests defaults to 10 connections per host per session; httpx defaults to 100. For batch LLM jobs, raise these or you will see subtle stalls.
Comparison Table
| Dimension | requests | httpx |
|---|---|---|
| Concurrency model | Blocking I/O, thread pool | Async/await + sync fallback |
| HTTP/2 support | No | Yes (with h2 extra) |
| Streaming API | iter_lines() sync |
aiter_lines() async / iter_lines() sync |
| Retry config | urllib3.Retry on adapter |
HTTPTransport(retries=) |
| Dependency footprint | ~2 MB, urllib3 only | Larger, asyncio + optional h2 |
| Learning curve | Trivial | Moderate (async mindset) |
| Best fit | Scripts, low concurrency | High-concurrency services, streaming |
Which to Choose
Use requests if:
- You are writing a one-off script, a Jupyter notebook, or a scheduled job with <10 concurrent calls.
- Your team has no async experience and the latency budget tolerates thread pools.
- You depend on a third-party SDK that already wraps
requests.
Use httpx (async) if:
- You run a production service that fans out to multiple LLMs or streams to many users simultaneously.
- You need HTTP/2 or fine-grained connection limits to a gateway.
- Your stack is already async (FastAPI, Starlette, anyio).
Use httpx (sync) if:
- You want
requests-like code but need built-in retries or future-proofing for async migration.
The python httpx vs requests llm decision is not about which is “better” universally; it is about matching the client to your concurrency profile. Pick the boring tool for boring loads, and reach for async only when the thread pool becomes the bottleneck.