140 lines
5.9 KiB
Markdown
140 lines
5.9 KiB
Markdown
---
|
|
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]]
|