API integration is the default task of modern software development. Payment processors, AI models, mapping services, identity providers, data pipelines — virtually every feature you build in 2026 calls an external API. The difference between brittle integrations that page you at 3am and reliable ones that just work comes down to a handful of patterns that most tutorials skip. Here they are.
What changed in 2026
httpx is the standard HTTP client — requests still works but lacks native async support; most new Python code uses httpx.
- OpenAPI 3.1 and typed SDK generation is universal — major API providers ship auto-generated Python SDKs. Use the SDK when available; drop to raw HTTP only when you need control.
- OAuth 2.0 PKCE is required for browser/mobile flows — the implicit flow is deprecated across all major providers.
- API versioning via URL path vs header is settled — path versioning (
/v2/) won; header-based versioning (X-API-Version: 2) is rare in new APIs.
- LLM APIs (OpenAI, Anthropic, etc.) are first-class targets — streaming responses via Server-Sent Events (SSE) are now a standard pattern to know.
The four auth patterns
| Pattern |
How it works |
When used |
| API Key |
Static secret in header or query param |
Server-to-server, simple integrations |
| OAuth 2.0 + PKCE |
Token exchange, user consent |
Third-party user data (Google, GitHub) |
| JWT (Bearer token) |
Signed token, self-contained claims |
Internal services, session auth |
| mTLS |
Client certificate validation |
High-security B2B APIs |
Making basic API calls with httpx
import httpx
# Synchronous — simple scripts
with httpx.Client(timeout=10) as client:
response = client.get(
"https://api.example.com/v2/users/me",
headers={"Authorization": "Bearer sk-your-token"},
)
response.raise_for_status() # raises HTTPStatusError on 4xx/5xx
user = response.json()
# Async — FastAPI, async scripts, concurrent calls
import asyncio
async def fetch_user(client: httpx.AsyncClient, user_id: str) -> dict:
response = await client.get(f"/users/{user_id}")
response.raise_for_status()
return response.json()
async def main():
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
# Fetch 10 users concurrently
tasks = [fetch_user(client, str(i)) for i in range(1, 11)]
users = await asyncio.gather(*tasks)
Retry with exponential backoff — the survival pattern
import httpx
import time
import random
def api_get_with_retry(url: str, headers: dict, max_retries: int = 3) -> dict:
for attempt in range(max_retries):
try:
response = httpx.get(url, headers=headers, timeout=10)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
time.sleep(retry_after)
continue
response.raise_for_status()
return response.json()
except httpx.TransportError:
if attempt == max_retries - 1:
raise
backoff = (2 ** attempt) + random.uniform(0, 1)
time.sleep(backoff)
raise RuntimeError("Max retries exceeded")
Or use tenacity for production-grade retry logic:
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=60),
retry=retry_if_exception_type(httpx.TransportError),
)
def fetch_data(url: str) -> dict:
response = httpx.get(url, timeout=10)
response.raise_for_status()
return response.json()
Pagination patterns
def paginate_cursor(base_url: str, headers: dict):
"""Cursor-based pagination — most modern APIs use this."""
cursor = None
while True:
params = {"limit": 100}
if cursor:
params["after"] = cursor
response = httpx.get(base_url, params=params, headers=headers)
response.raise_for_status()
data = response.json()
yield from data["items"]
cursor = data.get("next_cursor")
if not cursor:
break
def paginate_offset(base_url: str, headers: dict):
"""Offset-based pagination — older REST APIs."""
page, per_page = 1, 100
while True:
response = httpx.get(base_url, params={"page": page, "per_page": per_page}, headers=headers)
response.raise_for_status()
items = response.json()
if not items:
break
yield from items
page += 1
Streaming API responses (SSE / LLMs)
import httpx
def stream_llm_response(prompt: str, api_key: str):
"""Stream tokens from an LLM API using Server-Sent Events."""
with httpx.stream(
"POST",
"https://api.anthropic.com/v1/messages",
headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"},
json={
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"stream": True,
"messages": [{"role": "user", "content": prompt}],
},
timeout=60,
) as response:
for line in response.iter_lines():
if line.startswith("data: "):
yield line[6:] # strip "data: " prefix
API response error taxonomy
| Status |
Meaning |
Action |
| 400 Bad Request |
Your request is malformed |
Fix the request; log the body |
| 401 Unauthorized |
Missing or invalid credentials |
Refresh token or check API key |
| 403 Forbidden |
Authenticated but not allowed |
Check scopes/permissions; not retriable |
| 404 Not Found |
Resource does not exist |
Check the ID; not retriable |
| 422 Unprocessable |
Validation error |
Fix the payload; log the error body |
| 429 Too Many Requests |
Rate limited |
Back off per Retry-After header |
| 500/503 Server Error |
Their bug |
Retry with backoff; alert if persistent |
How to build a production API client
- Use a base client with shared config — base URL, auth headers, timeout, retry.
- Type your responses — Pydantic models for every response shape you parse.
- Log request/response metadata — URL, status, latency, request ID headers.
- Test with a mock — use
pytest-httpx or respx to mock httpx calls in tests.
- Handle pagination exhaustively — assume any endpoint could have 1M records.
Common mistakes
No timeout. httpx.get(url) without a timeout will hang forever on a stalled connection. Always pass timeout=10 (or higher for slow endpoints).
Ignoring error response bodies. response.raise_for_status() throws but discards the body. Log response.text or response.json() before raising — error details are in the body.
Storing API keys in code. Use environment variables or a secrets manager. Never commit API keys to git.
Sequential calls when concurrent is possible. Fetching 100 records one by one is 100x slower than using asyncio.gather with an async client. Profile before optimizing, but concurrent fetching is almost always the right choice.
Not versioning API calls. Hardcoding /v1/ everywhere without a constant means updating the version requires touching every file.
What to skip
urllib directly — it is low-level and verbose; httpx handles encoding, redirects, and connection pooling correctly.
- Custom session management —
httpx.Client with a context manager handles connection pooling. Do not reimplement it.
- Polling when webhooks exist — if the API offers webhooks, receive push events rather than polling every N seconds.
FAQ
requests or httpx in 2026?
httpx for new code — it is a near-drop-in replacement with async support, HTTP/2, and an actively maintained codebase. Use requests only when you need a plugin from its ecosystem that httpx does not have.
How do I test code that calls external APIs?
Use respx (for httpx) or responses (for requests) to mock HTTP calls in unit tests. For integration tests, record real responses with VCR cassettes.
What is the best way to manage API keys securely?
Environment variables loaded via python-dotenv locally, and a secrets manager (AWS Secrets Manager, GCP Secret Manager, Doppler) in production. Never hardcode in source.
How do I handle API versioning when the provider ships a new version?
Pin to the current version in a central config constant. When the provider releases v2, audit breaking changes, update the constant, and test. Never let the version drift silently.
Where to go next