--- title: httpx AsyncClient — Oliver Standard aliases: - httpx pattern - async http client tags: - python - httpx - shared-pattern - oliver sources: - cc-dashboard/src/services/azure_devops/client.py - cc-dashboard/src/services/mailgun.py - DevOps_Click_UP_sync/src/clients/ado.py - Barclays-banner-builder/backend/app/services/adobe_dam_client.py created: 2026-05-15 updated: 2026-05-15 --- ## Key Takeaways - `async with httpx.AsyncClient() as client:` is the dominant pattern across all Oliver projects — a new client is created per call, not shared at module level. - `resp.raise_for_status()` is called unconditionally after every request; error handling is done at the caller. - `timeout=` is passed per-call when the endpoint is known to be slow (Mailgun: 30 s, Adobe DAM OAuth: 15 s, Adobe DAM search: 15 s); internal/fast APIs omit it entirely. - `follow_redirects=True` is used explicitly only when required (ADO attachment downloads); omitted everywhere else. - Auth is built into headers at client construction time, not passed to each call site. ## Standard Pattern Pattern seen in `cc-dashboard` ADO client and `DevOps_Click_UP_sync` ADO client (identical structure): ```python import httpx class SomeServiceClient: def __init__(self): # Build auth headers once; reuse per request self.headers = { "Authorization": f"Bearer {settings.api_token}", "Content-Type": "application/json", } async def get_resource(self, resource_id: str) -> dict: url = f"{BASE_URL}/resources/{resource_id}" async with httpx.AsyncClient() as client: resp = await client.get(url, headers=self.headers) resp.raise_for_status() return resp.json() async def post_resource(self, body: dict) -> dict: url = f"{BASE_URL}/resources" async with httpx.AsyncClient() as client: resp = await client.post(url, json=body, headers=self.headers) resp.raise_for_status() return resp.json() async def patch_resource(self, resource_id: str, patch: list[dict]) -> dict: url = f"{BASE_URL}/resources/{resource_id}" # Override Content-Type for patch+json (ADO pattern) headers = {**self.headers, "Content-Type": "application/json-patch+json"} async with httpx.AsyncClient() as client: resp = await client.patch(url, json=patch, headers=headers) resp.raise_for_status() return resp.json() ``` Binary upload variant (from ADO attachment upload): ```python async def upload_bytes(self, filename: str, content: bytes) -> dict: url = f"{BASE_URL}/attachments?fileName={filename}" headers = { "Authorization": self.headers["Authorization"], "Content-Type": "application/octet-stream", } async with httpx.AsyncClient() as client: resp = await client.post(url, content=content, headers=headers) resp.raise_for_status() return resp.json() ``` ## Timeout Configuration Real values found in Oliver projects: | Use case | Timeout | Source | |----------|---------|--------| | Mailgun email send | `timeout=30` | `cc-dashboard/src/services/mailgun.py` | | Adobe DAM OAuth token | `timeout=15` | `Barclays-banner-builder/backend/app/services/adobe_dam_client.py` | | Adobe DAM search / get asset | `timeout=15` | same | | ADO API calls | none set | `cc-dashboard`, `DevOps_Click_UP_sync` | | ADO attachment download | none set | `DevOps_Click_UP_sync/src/clients/ado.py` | No projects define a module-level default timeout. For new integrations: use `timeout=15` for third-party APIs, omit for internal services. ## Error Handling All projects rely on `raise_for_status()` to propagate `httpx.HTTPStatusError`. Callers catch at the route/task level. No retry logic is implemented in any current Oliver project. ```python # What raise_for_status() raises: import httpx try: async with httpx.AsyncClient() as client: resp = await client.get(url, headers=headers) resp.raise_for_status() return resp.json() except httpx.HTTPStatusError as e: # e.response.status_code, e.response.text available raise except httpx.TimeoutException: # connect / read / write / pool timeout raise except httpx.RequestError: # network-level: DNS, connection refused, etc. raise ``` `mailgun.py` is the only place that catches the response status manually (`resp.status_code == 200`) instead of `raise_for_status()` — this is the Mailgun exception because a non-200 is a soft failure there. ## Gotchas **1. New client per call — no connection pooling.** Every `async with httpx.AsyncClient()` opens a new TCP connection. For high-throughput services (e.g., batch ADO fetches) this is a latency penalty. The fix is to instantiate `httpx.AsyncClient` at the class level and close it in a `__aenter__`/`__aexit__` or lifespan hook. No Oliver project does this yet. **2. `Content-Type` must be overridden for JSON-patch.** ADO's PATCH endpoints require `application/json-patch+json`. Using `json=` kwarg sets `Content-Type: application/json` automatically — you must spread and override the header dict manually (`{**self.headers, "Content-Type": "application/json-patch+json"}`). Forgetting this returns a 400 from ADO with no useful error body. **3. `data=` vs `json=` for form-encoded payloads.** Mailgun's API expects `multipart/form-data` / `application/x-www-form-urlencoded`, so it uses `data=` not `json=`. Using `json=` sends JSON body and Mailgun silently ignores it, returning 200 with no email sent. **4. `follow_redirects` defaults to `False`.** ADO attachment download URLs redirect. Without `httpx.AsyncClient(follow_redirects=True)` the client returns a 302 and `.content` is empty. Only set this flag when you know the endpoint redirects. ## Related - [[wiki/shared-patterns/structlog-setup]] - [[wiki/tech-patterns/fastapi-python-docker]] - [[wiki/tech-patterns/cost-tracker-integration]]