Why API Integration Patterns Matter in 2024
APIs are the backbone of modern software. Whether you’re connecting to payment processors, CRMs, AI services, or internal microservices, your integration quality determines reliability, performance, and security. In 2024, teams are raising the bar: OAuth is table stakes, JWTs are ubiquitous, and expectations for resiliency and observability are higher than ever.
This guide distills 10 essential Python API integration patterns you can plug into your stack today. Each pattern includes ready-to-use code snippets, with a focus on OAuth, JWT, error handling, and production-friendly practices.
Before you begin, consider a few baseline recommendations for 2024:
- Use Python 3.10+ (ideally 3.11+) for performance and typing improvements.
- Prefer httpx for its async support and modern API, but requests remains fine for simple sync tasks.
- Adopt Pydantic v2 for data validation and typed models.
- Treat secrets like secrets: environment variables and secret stores over hard-coded credentials.
1) Configuration and Secure Secrets Loading
Strong integrations start with clean configuration and no hard-coded secrets. Centralize base URLs, credentials, and timeouts using environment variables and typed settings.
Install:
pip install pydantic pydantic-settings httpx
Example settings and a base client:
from typing import Optional
from pydantic import BaseModel, SecretStr, HttpUrl, Field
from pydantic_settings import BaseSettings
import httpx
import logging
import os
logger = logging.getLogger("api")
logging.basicConfig(level=logging.INFO)
class Settings(BaseSettings):
api_base_url: HttpUrl
oauth_token_url: HttpUrl
client_id: str
client_secret: SecretStr
default_timeout_seconds: float = 10.0
model_config = {
"env_file": ".env",
"env_prefix": "MYAPI_",
}
settings = Settings() # Loads from env or .env
class ApiConfig(BaseModel):
base_url: HttpUrl = Field(default=settings.api_base_url)
timeout: float = Field(default=settings.default_timeout_seconds)
class ApiClient:
def __init__(self, config: Optional[ApiConfig] = None):
self.config = config or ApiConfig()
self.client = httpx.Client(
base_url=str(self.config.base_url),
timeout=httpx.Timeout(self.config.timeout, connect=5.0),
headers={"User-Agent": "myapp/1.0 (+https://example.com)"},
http2=True, # Opt-in to HTTP/2 if server supports it
)
def close(self):
self.client.close()
# Usage:
# export MYAPI_API_BASE_URL="https://api.example.com"
# export MYAPI_OAUTH_TOKEN_URL="https://auth.example.com/oauth/token"
# export MYAPI_CLIENT_ID="..."
# export MYAPI_CLIENT_SECRET="..."
Actionable tips:
- Use env prefixes per provider (e.g., STRIPE_API_KEY) to avoid collisions.
- Do not log secrets. If you log requests, scrub Authorization headers.
2) OAuth 2.0 Done Right (Client Credentials and Authorization Code)
Most enterprise APIs require OAuth. Two common flows:
- Client Credentials: server-to-server machine auth (no user).
- Authorization Code + Refresh: user-based auth where tokens expire and must be refreshed.
Install:
pip install httpx
Client Credentials token manager:
import time
from typing import Optional
import httpx
class OAuthToken:
def __init__(self, access_token: str, expires_in: int):
self.access_token = access_token
self.expiry_ts = time.time() + expires_in - 30 # refresh 30s early
def is_expired(self) -> bool:
return time.time() >= self.expiry_ts
class OAuthClientCredentials:
def __init__(self, token_url: str, client_id: str, client_secret: str):
self.token_url = token_url
self.client_id = client_id
self.client_secret = client_secret
self._token: Optional[OAuthToken] = None
def get_token(self) -> str:
if self._token is None or self._token.is_expired():
self._refresh()
return self._token.access_token
def _refresh(self):
# Prefer Basic auth if supported
resp = httpx.post(
self.token_url,
data={"grant_type": "client_credentials"},
auth=(self.client_id, self.client_secret),
timeout=10.0,
)
resp.raise_for_status()
data = resp.json()
self._token = OAuthToken(
access_token=data["access_token"],
expires_in=data.get("expires_in", 3600),
)
# Usage:
oauth = OAuthClientCredentials(
token_url=str(settings.oauth_token_url),
client_id=settings.client_id,
client_secret=settings.client_secret.get_secret_value(),
)
access_token = oauth.get_token()
Authorization Code with refresh:
class OAuthAuthorizationCode:
def __init__(self, token_url: str, client_id: str, client_secret: str, redirect_uri: str):
self.token_url = token_url
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self._access: Optional[OAuthToken] = None
self._refresh_token: Optional[str] = None
def exchange_code(self, code: str):
resp = httpx.post(
self.token_url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"client_id": self.client_id,
"client_secret": self.client_secret,
},
timeout=10.0,
)
resp.raise_for_status()
data = resp.json()
self._access = OAuthToken(data["access_token"], data.get("expires_in", 3600))
self._refresh_token = data.get("refresh_token")
def get_access_token(self) -> str:
if self._access is None:
raise RuntimeError("No access token. Exchange code first.")
if self._access.is_expired():
self.refresh()
return self._access.access_token
def refresh(self):
if not self._refresh_token:
raise RuntimeError("No refresh token available.")
resp = httpx.post(
self.token_url,
data={
"grant_type": "refresh_token",
"refresh_token": self._refresh_token,
"client_id": self.client_id,
"client_secret": self.client_secret,
},
timeout=10.0,
)
resp.raise_for_status()
data = resp.json()
self._access = OAuthToken(data["access_token"], data.get("expires_in", 3600))
if "refresh_token" in data:
self._refresh_token = data["refresh_token"]
Actionable tips:
- Refresh tokens ahead of expiry to avoid race conditions.
- Some providers rotate refresh tokens; always save the latest.
- Handle 401 by refreshing once and retrying the request.
3) JWT Verification and Claims Enforcement
JWTs carry identity and permissions. Validate them, don’t just trust presence. For RS256 tokens, verify using the provider’s JWKS.
Install:
pip install pyjwt cryptography httpx
JWT verification with JWKS:
import jwt
import httpx
from jwt import PyJWKClient, InvalidTokenError
def verify_jwt_rs256(token: str, jwks_url: str, audience: str, issuer: str) -> dict:
client = PyJWKClient(jwks_url)
signing_key = client.get_signing_key_from_jwt(token)
try:
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=audience,
issuer=issuer,
options={"require": ["exp", "iat", "iss", "aud"]},
)
return claims
except InvalidTokenError as e:
raise ValueError(f"Invalid JWT: {e}")
# Usage:
# claims = verify_jwt_rs256(id_token, "https://auth.example.com/.well-known/jwks.json", "your-aud", "https://auth.example.com/")
Actionable tips:
- Check exp, nbf, iss, aud. Enforce least privilege via scope/roles claims.
- Cache JWKS for a short TTL (e.g., 10–15 minutes) to reduce round-trips.
- Never log full tokens; if necessary, log only the first/last few characters.
4) Resilient HTTP: Timeouts, Retries, Backoff, and Circuit Breaking
Network hiccups happen. Build clients that fail fast, retry safely, and prevent cascading failures.
Install:
pip install httpx tenacity
A resilient requester with backoff:
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import httpx
class TransientError(Exception):
pass
def _should_retry(exc: Exception) -> bool:
return isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, TransientError))
@retry(
reraise=True,
stop=stop_after_attempt(4),
wait=wait_exponential(multiplier=0.5, min=0.5, max=8),
retry=retry_if_exception_type((httpx.HTTPError, TransientError)),
)
def robust_get(client: httpx.Client, url: str, headers=None) -> httpx.Response:
try:
resp = client.get(url, headers=headers)
if resp.status_code in {429, 500, 502, 503, 504}:
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", "1"))
time.sleep(min(retry_after, 5))
raise TransientError(f"Transient status {resp.status_code}")
return resp
except httpx.TimeoutException as e:
raise TransientError(f"Timeout: {e}") from e
Minimal circuit breaker:
import time
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_time=30):
self.failure_threshold = failure_threshold
self.recovery_time = recovery_time
self.failures = 0
self.open_until = 0.0
def allow(self) -> bool:
return time.time() >= self.open_until
def record_success(self):
self.failures = 0
def record_failure(self):
self.failures += 1
if self.failures >= self.failure_threshold:
self.open_until = time.time() + self.recovery_time
breaker = CircuitBreaker()
def safe_request(client: httpx.Client, method: str, url: str, **kwargs) -> httpx.Response:
if not breaker.allow():
raise RuntimeError("Circuit is open; fast-failing requests")
try:
resp = client.request(method, url, **kwargs)
if resp.status_code >= 500:
breaker.record_failure()
else:
breaker.record_success()
return resp
except httpx.HTTPError:
breaker.record_failure()
raise
Actionable tips:
- Always set timeouts. Default “forever” timeouts can hang threads.
- Retry only idempotent operations (GET, HEAD) or safe POSTs with idempotency keys.
5) Structured Error Handling with Custom Exceptions
Map provider-specific error shapes into your domain exceptions. This makes upstream business logic clean and testable.
Define exceptions and a handler:
from typing import Optional
import httpx
class APIError(Exception):
def __init__(self, message: str, status: int, code: Optional[str] = None, details: Optional[dict] = None):
super().__init__(message)
self.status = status
self.code = code
self.details = details or {}
class Unauthorized(APIError): pass
class NotFound(APIError): pass
class RateLimited(APIError): pass
class ValidationError(APIError): pass
class ServerError(APIError): pass
def raise_for_api_error(resp: httpx.Response):
if 200 <= resp.status_code < 300:
return
try:
payload = resp.json()
except Exception:
payload = {"message": resp.text}
message = payload.get("message") or payload.get("error_description") or "API error"
code = payload.get("code")
if resp.status_code == 401:
raise Unauthorized(message, resp.status_code, code, payload)
if resp.status_code == 404:
raise NotFound(message, resp.status_code, code, payload)
if resp.status_code == 429:
raise RateLimited(message, resp.status_code, code, payload)
if resp.status_code == 400:
raise ValidationError(message, resp.status_code, code, payload)
if resp.status_code >= 500:
raise ServerError(message, resp.status_code, code, payload)
raise APIError(message, resp.status_code, code, payload)
Actionable tips:
- Normalize error fields (message, code, field_errors) across providers.
- Log errors with correlation IDs if available (e.g., X-Request-Id header).
- Consider a retry path for 429/5xx only.
6) Pagination and Rate-Limit Friendly Iteration
APIs paginate to control payload sizes. Implement generic iterators that handle page- or cursor-based pagination and respect server rate limits.
Page-based iteration:
import time
from typing import Iterator, Dict, Any
def iter_pages(client: httpx.Client, path: str, page_size: int = 100) -> Iterator[Dict[str, Any]]:
page = 1
while True:
resp = client.get(path, params={"page": page, "per_page": page_size})
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", "1"))
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
items = data.get("items") or data # accommodate bare arrays
if not items:
break
for item in items:
yield item
if len(items) < page_size:
break
page += 1
Cursor-based iteration:
from typing import Iterator, Dict, Any, Optional
def iter_cursor(client: httpx.Client, path: str, cursor_param="cursor") -> Iterator[Dict[str, Any]]:
cursor: Optional[str] = None
while True:
params = {cursor_param: cursor} if cursor else {}
resp = client.get(path, params=params)
resp.raise_for_status()
payload = resp.json()
items = payload["data"]
for item in items:
yield item
cursor = payload.get("next_cursor")
if not cursor:
break
Actionable tips:
- If the API provides ETag, use If-None-Match to avoid re-downloading unchanged pages.
- Back off on 429 and respect Retry-After. Prefer smoothing your requests with sleeps or token buckets.
7) Async I/O and Concurrency Control with httpx
For high-throughput tasks (e.g., fetching thousands of records), async shines. Combine concurrency limits with retries to avoid overwhelming the API.
Install:
pip install httpx anyio
Async fetch with concurrency and simple rate limiting:
import asyncio
import httpx
from typing import List
async def fetch_one(client: httpx.AsyncClient, url: str) -> dict:
r = await client.get(url)
if r.status_code == 429:
retry_after = int(r.headers.get("Retry-After", "1"))
await asyncio.sleep(retry_after)
r = await client.get(url)
r.raise_for_status()
return r.json()
async def fetch_many(base_url: str, ids: List[str], concurrency: int = 10) -> List[dict]:
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(base_url=base_url, timeout=10.0, http2=True) as client:
async def bounded_fetch(item_id: str):
async with sem:
return await fetch_one(client, f"/resources/{item_id}")
tasks = [asyncio.create_task(bounded_fetch(i)) for i in ids]
return await asyncio.gather(*tasks, return_exceptions=False)
# Usage:
# results = asyncio.run(fetch_many("https://api.example.com", ["1", "2", "3"], concurrency=20))
Actionable tips:
- Tune concurrency to provider guidance (e.g., “max 20 concurrent requests”).
- For long-running jobs, monitor error rates and dynamically adjust concurrency.
8) Webhook Security: Signature Verification, Replay Protection, Idempotency
Webhooks invert control: you must trust incoming requests. Verify signatures, enforce time windows, and ensure idempotent processing.
Install:
pip install fastapi uvicorn
FastAPI webhook endpoint with HMAC signature:
import hmac, hashlib, time, json
from fastapi import FastAPI, Request, HTTPException
from starlette.status import HTTP_200_OK
WEBHOOK_SECRET = os.environ.get("MYAPI_WEBHOOK_SECRET", "").encode()
app = FastAPI()
# Simple in-memory dedupe for demo; use Redis or DB in production
seen_event_ids = set()
def verify_signature(body: bytes, timestamp: str, signature: str, secret: bytes) -> bool:
payload = timestamp.encode() + b"." + body
expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhook")
async def webhook(request: Request):
timestamp = request.headers.get("X-Signature-Timestamp", "")
signature = request.headers.get("X-Signature", "")
if not timestamp or not signature:
raise HTTPException(400, "Missing signature headers")
now = int(time.time())
try:
ts = int(timestamp)
except ValueError:
raise HTTPException(400, "Invalid timestamp")
if abs(now - ts) > 300: # 5-minute window
raise HTTPException(400, "Stale timestamp")
body = await request.body()
if not verify_signature(body, timestamp, signature, WEBHOOK_SECRET):
raise HTTPException(401, "Invalid signature")
event = json.loads(body)
event_id = event.get("id")
if not event_id:
raise HTTPException(400, "Missing event id")
if event_id in seen_event_ids:
return {"status": "duplicate"} # idempotent response
seen_event_ids.add(event_id)
# Process event safely here; wrap with try/except and enqueue for async processing
# ...
return {"status": "ok"}
Actionable tips:
- Reject stale timestamps to prevent replay attacks.
- Use idempotency keys (event_id) to dedupe. Persist these keys for at least 24–72 hours.
- Acknowledge quickly; do heavy work asynchronously.
9) GraphQL Consumption with Typed Models and Cursors
GraphQL is flexible but demands structured handling of errors and pagination.
Simple GraphQL query with typing:
import httpx
from pydantic import BaseModel
from typing import List, Optional
class User(BaseModel):
id: str
email: str
createdAt: str
class UsersResponse(BaseModel):
users: List[User]
GQL_QUERY = """
query Users($after: String) {
users(first: 100, after: $after) {
edges {
cursor
node { id email createdAt }
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
def graphql_request(client: httpx.Client, endpoint: str, query: str, variables: dict) -> dict:
resp = client.post(endpoint, json={"query": query, "variables": variables})
resp.raise_for_status()
data = resp.json()
if "errors" in data:
raise APIError("GraphQL error", 400, details={"errors": data["errors"]})
return data["data"]
def iter_users(client: httpx.Client, endpoint: str):
after: Optional[str] = None
while True:
data = graphql_request(client, endpoint, GQL_QUERY, {"after": after})
edges = data["users"]["edges"]
for edge in edges:
yield User(**edge["node"])
page_info = data["users"]["pageInfo"]
if not page_info["hasNextPage"]:
break
after = page_info["endCursor"]
Actionable tips:
- Batch fields you actually need to reduce payload.
- Validate GraphQL responses with Pydantic to catch schema drift early.
- Handle partial failures: GraphQL can return data with errors; decide your strategy.
10) Files, Multipart Uploads, and Streaming Downloads
Many APIs accept file uploads or provide large downloadable assets. Use streaming to avoid memory blow-ups and provide progress feedback.
Multipart file upload:
import httpx
from pathlib import Path
def upload_file(client: httpx.Client, path: str, file_path: Path, extra: dict = None) -> dict:
with file_path.open("rb") as f:
files = {"file": (file_path.name, f, "application/octet-stream")}
resp = client.post(path, files=files, data=extra or {})
resp.raise_for_status()
return resp.json()
Streaming download with progress:
from typing import Callable, Optional
def download_stream(client: httpx.Client, url: str, dest: Path, on_progress: Optional[Callable[[int, Optional[int]], None]] = None):
with client.stream("GET", url) as r:
r.raise_for_status()
total = int(r.headers.get("Content-Length", "0")) or None
downloaded = 0
with dest.open("wb") as f:
for chunk in r.iter_bytes():
f.write(chunk)
downloaded += len(chunk)
if on_progress:
on_progress(downloaded, total)
# Usage:
# download_stream(api.client, "/reports/2024/export", Path("export.csv"), lambda d, t: print(f"{d}/{t or '?'} bytes"))
Actionable tips:
- Respect upload limits; chunk large files if the API supports it.
- For resumable downloads, request range bytes and manage partial files.
Putting It Together: A Minimal, Production-Friendly API Client
Let’s combine several patterns—OAuth, resilient requests, and structured errors—into a concise client you can adapt.
from typing import Any, Dict, Optional
import time
import httpx
class OAuthHTTPClient:
def __init__(self, config: ApiConfig, token_manager: OAuthClientCredentials):
self.config = config
self.token_manager = token_manager
self.client = httpx.Client(
base_url=str(config.base_url),
timeout=httpx.Timeout(config.timeout, connect=5.0),
http2=True,
)
def _auth_headers(self) -> Dict[str, str]:
token = self.token_manager.get_token()
return {"Authorization": f"Bearer {token}"}
def request(self, method: str, path: str, retries: int = 2, **kwargs) -> httpx.Response:
for attempt in range(retries + 1):
headers = kwargs.pop("headers", {})
headers.update(self._auth_headers())
try:
resp = self.client.request(method, path, headers=headers, **kwargs)
if resp.status_code == 401 and attempt < retries:
# refresh token and retry once
self.token_manager._refresh()
continue
if resp.status_code == 429 and attempt < retries:
wait = int(resp.headers.get("Retry-After", "1"))
time.sleep(wait)
continue
if resp.status_code in {500, 502, 503, 504} and attempt < retries:
time.sleep(2 ** attempt)
continue
raise_for_api_error(resp)
return resp
except (httpx.ConnectError, httpx.ReadTimeout) as e:
if attempt >= retries:
raise
time.sleep(2 ** attempt)
raise RuntimeError("Exhausted retries")
def get_json(self, path: str, **kwargs) -> Dict[str, Any]:
resp = self.request("GET", path, **kwargs)
return resp.json()
def post_json(self, path: str, json: dict, **kwargs) -> Dict[str, Any]:
resp = self.request("POST", path, json=json, **kwargs)
return resp.json()
# Usage example:
# config = ApiConfig()
# tm = OAuthClientCredentials(str(settings.oauth_token_url), settings.client_id, settings.client_secret.get_secret_value())
# client = OAuthHTTPClient(config, tm)
# data = client.get_json("/v1/accounts/me")
Enhancements to consider:
- Add metrics (request counts, latencies, errors) via Prometheus or OpenTelemetry.
- Centralize logging with correlation IDs (pass-through X-Request-Id).
- Feature-flag concurrency limits to adapt under load.
Testing and Observability Best Practices
Even the best patterns fail without good tests and visibility.
- Test against recorded fixtures: use respx or responses to mock HTTP.
- Property-based testing: verify pagination iterators against random data.
- Contract tests: verify schema with Pydantic models and sample payloads.
- Structured logging: include method, path, status, latency, and request id.
- Metrics: track p95 latency, error rate by status code, and retry counts.
- Tracing: instrument calls with OpenTelemetry for distributed traces end-to-end.
Install helpful dev tools:
pip install respx pytest pytest-asyncio opentelemetry-sdk opentelemetry-instrumentation-httpx
Common Pitfalls to Avoid in 2024
- Silent retries on non-idempotent operations. Always use idempotency keys for POST/PUT that create resources.
- Logging tokens. Mask or omit Authorization headers in logs.
- Ignoring Retry-After. Respect server guidance to avoid bans.
- Not handling partial failures in GraphQL or batch endpoints.
- Global mutable clients without thread-safety. Use per-thread or async-local clients.
- Overly aggressive concurrency. Start conservative and ramp up with real metrics.
Final Thoughts
API integration is a craft: it’s about clear contracts, defensive coding, and thoughtful error handling. With these 10 patterns—secure configuration, robust OAuth and JWT handling, resilient HTTP practices, careful pagination, async concurrency, secure webhooks, typed GraphQL, and efficient file transfer—you’ll be ready for production-grade integrations in 2024 and beyond.
Take these snippets, adapt them to your providers, and layer on your observability. Your future self (and your incident pager) will thank you.