Skip to content

HTTP with httpx and requests

The rules of thumb: retry transport errors, timeouts, 429 and 5xx; never retry other 4xx (they are permanent); honor Retry-After; always set a transport timeout too.

Retry-After, as nopanic understands it

nopanic honors a numeric retry_after attribute (seconds) on the exception. Set it from the header once, in one place:

from datetime import datetime, timezone
from email.utils import parsedate_to_datetime

def retry_after_seconds(value: str | None) -> float | None:
    """Parse a Retry-After header: delay-seconds or an HTTP date."""
    if not value:
        return None
    try:
        return max(0.0, float(value))
    except ValueError:
        pass
    try:
        when = parsedate_to_datetime(value)
    except (TypeError, ValueError):
        return None
    return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())

retry caps the hint by retry_after_cap (default 60 s), so a hostile header cannot park your client.

httpx (async)

import httpx
from nopanic import RetryBudget, adaptive_rate_limit, backoff, circuit_breaker, retry, timeout

client = httpx.AsyncClient(base_url="https://api.example.com", timeout=10.0)

def retriable(exc: BaseException) -> bool:
    if isinstance(exc, httpx.TransportError):          # connect errors, read timeouts...
        return True
    if isinstance(exc, httpx.HTTPStatusError):
        status = exc.response.status_code
        return status == 429 or status >= 500
    return False

api_budget = RetryBudget(ratio=0.1)
api_breaker = circuit_breaker(name="example-api", min_calls=20, on=retriable)
api_rate = adaptive_rate_limit(50, per=1.0, min_rate=5)   # learns the real limit from 429s

@retry(attempts=4, on=retriable, backoff=backoff.full_jitter(0.2, cap=10.0), budget=api_budget)
@api_breaker
@api_rate
@timeout(15.0)
async def get_json(path: str, **params) -> dict:
    response = await client.get(path, params=params)
    try:
        response.raise_for_status()
    except httpx.HTTPStatusError as exc:
        exc.retry_after = retry_after_seconds(exc.response.headers.get("retry-after"))
        raise
    return response.json()

on=retriable on the breaker means a 404 is neither a failure nor a success: only the dependency's own failures count. adaptive_rate_limit's default looks_throttled recognizes the 429 through exc.response.status_code and the retry_after attribute.

requests (sync)

import requests
from nopanic import backoff, retry

def retriable(exc: BaseException) -> bool:
    if isinstance(exc, (requests.ConnectionError, requests.Timeout)):
        return True
    return (
        isinstance(exc, requests.HTTPError)
        and exc.response is not None
        and (exc.response.status_code == 429 or exc.response.status_code >= 500)
    )

@retry(attempts=4, on=retriable, backoff=backoff.full_jitter(base=0.2, cap=10.0))
def get_json(url: str) -> dict:
    response = requests.get(url, timeout=10)
    try:
        response.raise_for_status()
    except requests.HTTPError as exc:
        exc.retry_after = retry_after_seconds(response.headers.get("Retry-After"))
        raise
    return response.json()

Responses that do not raise

If you would rather not raise on status codes, retry on the result instead:

@retry(attempts=4, on=httpx.TransportError, on_result=lambda r: r.status_code in (429, 502, 503, 504))
async def get(path: str) -> httpx.Response:
    return await client.get(path)

Hand the remaining budget to the client

Inside a deadline, give each request only the time that is left:

from nopanic import time_remaining

response = await client.get(path, timeout=time_remaining() or 10.0)