HTTPX + Tenacity + asyncio boundaries
Make the whole call path bounded, cancellation-safe, and semantically retryable. A
retry loop is not resilience when each attempt has an unbounded timeout, duplicates a
side effect, or overwhelms the connection pool.
Inspect the installed version of HTTPX, Tenacity, and Python and their signatures before
encoding version-sensitive exception, timeout, or cancellation behavior.
Establish the contract
Before coding, identify:
- operation semantics: read, idempotent write, or non-idempotent write;
- end-to-end deadline and per-attempt connect/read/write/pool budgets;
- retryable failures defined by the remote protocol, not by
Exception;
- maximum concurrency, connection-pool limits, and upstream rate limit;
- cancellation and shutdown behavior;
- idempotency key or deduplication mechanism for retryable writes.
If those facts are unknown, inspect the upstream API contract before adding retries.
Execution model
- Reuse one
httpx.AsyncClient for the service lifetime. Do not create a client per
request in a hot path.
- Bound HTTPX connect, read, write, and pool waits. Bound the complete operation with
an outer deadline when several attempts must share one budget.
- Use Tenacity's
AsyncRetrying; never block the event loop with time.sleep or a
synchronous client.
- Retry only transient transport failures and explicitly retryable status codes such
as 429 or selected 5xx responses. Honor
Retry-After where supported.
- Propagate
asyncio.CancelledError. Cancellation is control flow, not a transient
remote failure.
- Limit concurrency independently from connection-pool capacity. A semaphore is an
admission control, not a timeout.
- Close the client during application shutdown after in-flight ownership is clear.
Read budgets.md for deadline allocation,
retry-semantics.md for eligibility, and
lifecycle.md for client and task ownership.
Canonical shape
import asyncio
import httpx
from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_attempt
from tenacity import wait_random_exponential
async def fetch_json(client: httpx.AsyncClient, url: str) -> object:
async with asyncio.timeout(12):
async for attempt in AsyncRetrying(
stop=stop_after_attempt(3),
wait=wait_random_exponential(multiplier=0.2, max=2),
retry=retry_if_exception_type(
(httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout)
),
reraise=True,
):
with attempt:
response = await client.get(url)
response.raise_for_status()
return response.json()
raise AssertionError("retry loop returned no result")
Do not copy this exception set blindly. For example, retrying a ReadTimeout after a
write may duplicate a side effect because the server might have committed it before
the response was lost.
Completion checks
- Tests cover success, terminal 4xx, retryable failure then success, exhausted budget,
and task cancellation.
- A retryable write has an idempotency contract.
- Total attempts and latency cannot exceed the stated budget except for bounded
scheduler delay.
- Concurrency and pool limits are explicit and observability records attempts without
leaking credentials or bodies.
- No blocking sleep or synchronous network call remains on the async path.
1---2name: httpx-tenacity-asyncio3description: >-4 Use for designing or reviewing resilient asynchronous HTTP call paths that combine5 HTTPX, Tenacity, and asyncio: client lifetime, time budgets, retry eligibility,6 backoff, concurrency, cancellation, and idempotency. Do not use for generic HTTPX,7 retry, or asyncio questions that do not cross these boundaries.8---910# HTTPX + Tenacity + asyncio boundaries1112Make the whole call path bounded, cancellation-safe, and semantically retryable. A13retry loop is not resilience when each attempt has an unbounded timeout, duplicates a14side effect, or overwhelms the connection pool.1516Inspect the installed version of HTTPX, Tenacity, and Python and their signatures before17encoding version-sensitive exception, timeout, or cancellation behavior.1819## Establish the contract2021Before coding, identify:2223- operation semantics: read, idempotent write, or non-idempotent write;24- end-to-end deadline and per-attempt connect/read/write/pool budgets;25- retryable failures defined by the remote protocol, not by `Exception`;26- maximum concurrency, connection-pool limits, and upstream rate limit;27- cancellation and shutdown behavior;28- idempotency key or deduplication mechanism for retryable writes.2930If those facts are unknown, inspect the upstream API contract before adding retries.3132## Execution model33341. Reuse one `httpx.AsyncClient` for the service lifetime. Do not create a client per35 request in a hot path.362. Bound HTTPX connect, read, write, and pool waits. Bound the complete operation with37 an outer deadline when several attempts must share one budget.383. Use Tenacity's `AsyncRetrying`; never block the event loop with `time.sleep` or a39 synchronous client.404. Retry only transient transport failures and explicitly retryable status codes such41 as 429 or selected 5xx responses. Honor `Retry-After` where supported.425. Propagate `asyncio.CancelledError`. Cancellation is control flow, not a transient43 remote failure.446. Limit concurrency independently from connection-pool capacity. A semaphore is an45 admission control, not a timeout.467. Close the client during application shutdown after in-flight ownership is clear.4748Read [budgets.md](references/budgets.md) for deadline allocation,49[retry-semantics.md](references/retry-semantics.md) for eligibility, and50[lifecycle.md](references/lifecycle.md) for client and task ownership.5152## Canonical shape5354```python55import asyncio5657import httpx58from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_attempt59from tenacity import wait_random_exponential606162async def fetch_json(client: httpx.AsyncClient, url: str) -> object:63 async with asyncio.timeout(12):64 async for attempt in AsyncRetrying(65 stop=stop_after_attempt(3),66 wait=wait_random_exponential(multiplier=0.2, max=2),67 retry=retry_if_exception_type(68 (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout)69 ),70 reraise=True,71 ):72 with attempt:73 response = await client.get(url)74 response.raise_for_status()75 return response.json()76 raise AssertionError("retry loop returned no result")77```7879Do not copy this exception set blindly. For example, retrying a `ReadTimeout` after a80write may duplicate a side effect because the server might have committed it before81the response was lost.8283## Completion checks8485- Tests cover success, terminal 4xx, retryable failure then success, exhausted budget,86 and task cancellation.87- A retryable write has an idempotency contract.88- Total attempts and latency cannot exceed the stated budget except for bounded89 scheduler delay.90- Concurrency and pool limits are explicit and observability records attempts without91 leaking credentials or bodies.92- No blocking sleep or synchronous network call remains on the async path.