ProvenPin Install

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
pinned versions5
httpx@0.28.1tenacity@9.2.1respx@0.23.1pytest-asyncio@1.4.0pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
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.

requirements.txt
httpx==0.28.1
tenacity==9.2.1
respx==0.23.1
pytest-asyncio==1.4.0
pytest==9.1.1
client.py
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()
tests/test_client.py
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.ini
[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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.