httpx + tenacity async retries honoring Retry-After, tested with respx: working versions (tested 2026-10-15)
Wrap async httpx calls in tenacity AsyncRetrying that retries 429 and 5xx, honors Retry-After, and patches the wait to zero in tests with respx mocks.
passed5 passed · 0 failed2026-10-15 13:34 UTC- Status
- passed
- Last passed
- 2026-10-15 13:34 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("tenacity-httpx-respx-backoff") (install), or see them on GitHub.
httpx==0.28.1 tenacity==9.2.1 respx==0.23.1 pytest-asyncio==1.4.0 pytest==9.1.1
import asyncio
import email.utils
from dataclasses import dataclass
from datetime import datetime, timezone
import httpx
from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_exponential
_backoff = wait_exponential(multiplier=0.5, max=8)
@dataclass(frozen=True)
class Repo:
id: int
full_name: str
stargazers_count: int
def _retry_after(response: httpx.Response) -> float | None:
value = response.headers.get("Retry-After")
if value is None:
return None
if value.isdigit():
return float(value)
# Retry-After may also be an HTTP date
try:
when = email.utils.parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
def _wait(retry_state) -> float:
exc = retry_state.outcome.exception()
if isinstance(exc, httpx.HTTPStatusError):
delay = _retry_after(exc.response)
if delay is not None:
return delay
return _backoff(retry_state)
def _should_retry(exc: BaseException) -> bool:
if isinstance(exc, httpx.TransportError):
return True
if isinstance(exc, httpx.HTTPStatusError):
code = exc.response.status_code
return code == 429 or code >= 500
return False
class ApiClient:
def __init__(self, base_url: str = "https://api.example.com", attempts: int = 3, sleep=asyncio.sleep):
self.http = httpx.AsyncClient(base_url=base_url, timeout=10)
self.attempts = attempts
self.sleep = sleep
async def _get(self, path: str) -> httpx.Response:
# A fresh AsyncRetrying per call: retry state lives on the instance,
# so sharing one across concurrent calls would mix up their attempts.
retrying = AsyncRetrying(
stop=stop_after_attempt(self.attempts),
wait=_wait,
retry=retry_if_exception(_should_retry),
sleep=self.sleep,
reraise=True, # raise the original error, not tenacity.RetryError
)
async for attempt in retrying:
with attempt:
r = await self.http.get(path)
# must raise inside the block, or a 5xx is returned as a success and never retried
r.raise_for_status()
return r
async def get_repo(self, owner: str, name: str) -> Repo:
r = await self._get(f"/repos/{owner}/{name}")
d = r.json()
# pick known fields explicitly so extra API keys are ignored
return Repo(id=d["id"], full_name=d["full_name"], stargazers_count=d["stargazers_count"])
async def aclose(self):
await self.http.aclose()
import httpx, pytest, respx
from client import ApiClient
URL = "https://api.example.com/repos/a/b"
REPO_JSON = {"id": 1, "full_name": "a/b", "stargazers_count": 5}
def make_client(sleeps: list[float]) -> ApiClient:
# wait is patched to zero: the injected sleep records the delay instead of waiting
async def fake_sleep(seconds: float):
sleeps.append(seconds)
return ApiClient(sleep=fake_sleep)
@pytest.mark.asyncio
@respx.mock
async def test_retry_after_seconds_is_honored():
route = respx.get(URL).mock(side_effect=[
httpx.Response(429, headers={"Retry-After": "7"}),
httpx.Response(200, json=REPO_JSON),
])
sleeps: list[float] = []
c = make_client(sleeps)
repo = await c.get_repo("a", "b")
assert sleeps == [7.0]
assert route.call_count == 2 and repo.stargazers_count == 5
await c.aclose()
@pytest.mark.asyncio
@respx.mock
async def test_5xx_uses_exponential_backoff():
route = respx.get(URL).mock(side_effect=[
httpx.Response(503),
httpx.Response(503),
httpx.Response(200, json=REPO_JSON),
])
sleeps: list[float] = []
c = make_client(sleeps)
await c.get_repo("a", "b")
assert route.call_count == 3
assert len(sleeps) == 2 and all(s > 0 for s in sleeps)
await c.aclose()
@pytest.mark.asyncio
@respx.mock
async def test_404_is_not_retried():
route = respx.get(URL).mock(return_value=httpx.Response(404))
sleeps: list[float] = []
c = make_client(sleeps)
with pytest.raises(httpx.HTTPStatusError):
await c.get_repo("a", "b")
assert route.call_count == 1 and sleeps == []
await c.aclose()
@pytest.mark.asyncio
@respx.mock
async def test_exhausted_retries_reraise_original_error():
route = respx.get(URL).mock(return_value=httpx.Response(503))
sleeps: list[float] = []
c = make_client(sleeps)
# reraise=True means the original HTTPStatusError surfaces, not tenacity.RetryError
with pytest.raises(httpx.HTTPStatusError):
await c.get_repo("a", "b")
assert route.call_count == 3 and len(sleeps) == 2
await c.aclose()
@pytest.mark.asyncio
@respx.mock
async def test_transport_error_is_retried():
route = respx.get(URL).mock(side_effect=[
httpx.ConnectError("boom"),
httpx.Response(200, json=REPO_JSON),
])
sleeps: list[float] = []
c = make_client(sleeps)
repo = await c.get_repo("a", "b")
assert route.call_count == 2 and repo.full_name == "a/b"
await c.aclose()
[pytest] pythonpath = . asyncio_default_fixture_loop_scope = function
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
httpx client + tenacity retries with backoff, tested with respx
Retry transient HTTP failures (5xx, 429, timeouts) with tenacity around an httpx client, honoring a max attempt count, tested offline with respx.
httpx async API client + respx mocks
A typed async API client on httpx with retries on 5xx and Pydantic parsing, tested offline with respx route mocks.
httpx OAuth2 client-credentials client with cached tokens and Link pagination
A sync httpx client that fetches and caches an OAuth2 client-credentials token, refreshes it on 401, and follows Link-header pagination, tested offline with respx routes.
Typed httpx client parsing into pydantic v2 models, tested with respx
An async httpx client that retries 5xx and transport errors, validates responses into pydantic v2 models, and is tested offline with respx route mocks.