httpx is Python’s modern HTTP client library β it supports both synchronous and asynchronous requests, has a clean API similar to the popular requests library, and is the standard choice for HTTP calls in FastAPI projects. When your FastAPI application needs to call other APIs β a payment gateway, an OAuth provider, an email service, or an external data source β httpx is the tool. The key distinction for FastAPI is async: FastAPI runs on an async event loop, and making synchronous HTTP calls inside async route handlers blocks the event loop. httpx.AsyncClient avoids this.
Synchronous Requests
import httpx
# ββ GET request βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
response = httpx.get("https://api.github.com/users/tiangolo")
print(response.status_code) # 200
print(response.headers["content-type"]) # application/json
data = response.json() # parse JSON body
print(data["name"]) # SebastiΓ‘n RamΓrez
# ββ With query parameters βββββββββββββββββββββββββββββββββββββββββββββββββββββ
response = httpx.get(
"https://api.github.com/search/repositories",
params={"q": "fastapi", "sort": "stars", "per_page": 5}
)
# URL: .../search/repositories?q=fastapi&sort=stars&per_page=5
# ββ POST with JSON body ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
response = httpx.post(
"https://api.example.com/auth/token",
json={"username": "alice", "password": "secret"}, # auto-sets Content-Type: application/json
headers={"Accept": "application/json"},
)
# ββ PUT / PATCH / DELETE ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
httpx.put("https://api.example.com/posts/1", json={"title": "Updated"})
httpx.patch("https://api.example.com/posts/1", json={"published": True})
httpx.delete("https://api.example.com/posts/1")
# ββ With authentication βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
token = "eyJhbGciOiJIUzI1NiJ9..."
response = httpx.get(
"https://api.example.com/me",
headers={"Authorization": f"Bearer {token}"}
)
httpx.Client() as a context manager when making multiple requests to the same base URL β it reuses TCP connections (connection pooling) and applies shared settings like base URL, headers, and timeouts to all requests. Using httpx.get() as a standalone function creates a new connection for every call. The same applies to httpx.AsyncClient() for async code β create one client per application lifecycle, not one per request.httpx.Timeout(connect=5.0, read=30.0) to set separate timeouts for the connection and response read phases.httpx.get() or requests.get() inside an async def FastAPI route handler. Synchronous I/O blocks the event loop, preventing all other concurrent requests from being handled until the HTTP call completes. In a busy application, one slow external API call blocks all traffic. Always use await async_client.get() inside async handlers.Asynchronous Requests β For FastAPI
import httpx
# ββ Single async request βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def fetch_user_from_github(username: str) -> dict:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(f"https://api.github.com/users/{username}")
response.raise_for_status() # raises HTTPStatusError on 4xx/5xx
return response.json()
# ββ Reusable async client (application lifetime) ββββββββββββββββββββββββββββββ
# Better: create once, reuse across requests
class ExternalAPIClient:
def __init__(self, base_url: str, api_key: str):
self.client = httpx.AsyncClient(
base_url = base_url,
headers = {"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"},
timeout = httpx.Timeout(connect=5.0, read=30.0),
)
async def get(self, path: str, **kwargs) -> dict:
response = await self.client.get(path, **kwargs)
response.raise_for_status()
return response.json()
async def post(self, path: str, data: dict) -> dict:
response = await self.client.post(path, json=data)
response.raise_for_status()
return response.json()
async def close(self):
await self.client.aclose()
# ββ Parallel async requests βββββββββββββββββββββββββββββββββββββββββββββββββββ
import asyncio
async def fetch_multiple(urls: list[str]) -> list[dict]:
async with httpx.AsyncClient(timeout=10.0) as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks, return_exceptions=True)
results = []
for url, response in zip(urls, responses):
if isinstance(response, Exception):
results.append({"url": url, "error": str(response)})
elif response.status_code == 200:
results.append({"url": url, "data": response.json()})
else:
results.append({"url": url, "error": f"HTTP {response.status_code}"})
return results
Error Handling with httpx
import httpx
async def safe_get(url: str) -> dict | None:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
# raise_for_status() raises HTTPStatusError for 4xx/5xx
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
print(f"Request timed out: {url}")
return None
except httpx.ConnectError:
print(f"Cannot connect to: {url}")
return None
except httpx.HTTPStatusError as e:
print(f"HTTP error {e.response.status_code}: {url}")
if e.response.status_code == 404:
return None
raise # re-raise for 5xx errors
except httpx.RequestError as e:
# Base class for all connection-related errors
print(f"Request failed: {e}")
return None
# httpx exception hierarchy:
# httpx.RequestError β base for all request issues
# httpx.ConnectError β cannot connect
# httpx.TimeoutException β timeout
# httpx.ConnectTimeout
# httpx.ReadTimeout
# httpx.WriteTimeout
# httpx.HTTPStatusError β 4xx or 5xx response (from raise_for_status())
httpx vs requests
| Feature | requests | httpx |
|---|---|---|
| Sync requests | Yes | Yes |
| Async requests | No | Yes β AsyncClient |
| HTTP/2 | No | Yes (optional) |
| Type annotations | Partial | Full |
| FastAPI test client | No | Yes β TestClient |
| Recommended for FastAPI | No (sync only) | Yes |
Common Mistakes
Mistake 1 β Blocking async route with sync HTTP call
β Wrong β blocks the event loop:
@app.get("/external")
async def get_external():
data = httpx.get("https://api.example.com/data").json() # BLOCKS event loop!
return data
β Correct β use async client:
@app.get("/external")
async def get_external():
async with httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/data")
return response.json() # β non-blocking
Mistake 2 β No timeout on external requests
β Wrong β no timeout, blocks indefinitely:
async with httpx.AsyncClient() as client: # default timeout: 5s, but can be None
response = await client.get(url) # could hang for minutes
β Correct β always set a timeout:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url) # raises TimeoutException after 10s β
Mistake 3 β Creating a new AsyncClient per request
β Wrong β new client (new connection pool) created for every HTTP request:
@app.get("/posts/{id}")
async def get_post(id: int):
async with httpx.AsyncClient() as client: # new pool every request β inefficient
...
β Better β create one client at startup and share it via FastAPI state or dependency:
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http_client = httpx.AsyncClient(timeout=10.0)
yield
await app.state.http_client.aclose() # β shared, closed on shutdown
Quick Reference
| Task | Code |
|---|---|
| Sync GET | httpx.get(url, params={...}) |
| Async GET | await client.get(url) |
| POST with JSON | await client.post(url, json={...}) |
| Auth header | headers={"Authorization": f"Bearer {token}"} |
| Raise on error | response.raise_for_status() |
| Parse JSON | response.json() |
| Timeout | AsyncClient(timeout=10.0) |
| Parallel requests | await asyncio.gather(*[client.get(u) for u in urls]) |