Working With HTTP APIs¶
What You'll Learn¶
- How to use a reusable
httpxclient with sensible timeouts - How to retry the right failures with exponential backoff and jitter
- How to handle rate limits, pagination, and authentication
- How to turn HTTP failures into clear errors your tool can act on
Use a Client, Not One-Off Calls¶
import httpx
with httpx.Client(
base_url="https://api.github.com",
headers={"Accept": "application/vnd.github+json", "User-Agent": "opsctl/0.1"},
timeout=httpx.Timeout(10.0, connect=3.0),
) as client:
r = client.get("/repos/prometheus/prometheus/releases/latest")
r.raise_for_status()
print(r.json()["tag_name"])
A client reuses TCP and TLS connections across requests (much faster), and holds shared settings: base URL, headers, authentication, and timeouts.
httpx has an API very close to requests and adds HTTP/2 and async support. Everything on this page applies to requests with small syntax changes.
Timeouts Are Not Optional¶
Without a timeout, a request to a server that accepts the connection but never responds waits forever. httpx defaults to 5 seconds; set timeouts deliberately anyway:
timeout = httpx.Timeout(
connect=3.0, # establishing the TCP and TLS connection
read=15.0, # waiting for response data
write=10.0,
pool=5.0, # waiting for a free connection from the pool
)
Handling Errors¶
import logging
import httpx
log = logging.getLogger(__name__)
def get_json(client: httpx.Client, path: str) -> dict:
try:
r = client.get(path)
r.raise_for_status()
return r.json()
except httpx.HTTPStatusError as exc:
# The server responded with 4xx or 5xx
log.error("%s %s returned %s: %s", exc.request.method, exc.request.url,
exc.response.status_code, exc.response.text[:200])
raise
except httpx.TransportError as exc:
# DNS failure, connection refused, timeout, TLS error
log.error("could not reach %s: %s", exc.request.url, exc)
raise
Retries With Backoff¶
Retry transient failures only:
| Retry | Don't retry |
|---|---|
| Timeouts and connection errors | 400, 401, 403, 404, 422 — retrying won't change the answer |
429 Too Many Requests (respect Retry-After) |
Non-idempotent requests (POST) unless the API supports idempotency keys |
502, 503, 504 |
Use tenacity to express the policy clearly:
import logging
import httpx
from tenacity import (
before_sleep_log,
retry,
retry_if_exception,
stop_after_attempt,
wait_exponential_jitter,
)
log = logging.getLogger(__name__)
RETRYABLE_STATUS = {429, 502, 503, 504}
def _is_retryable(exc: BaseException) -> bool:
if isinstance(exc, httpx.TransportError):
return True
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code in RETRYABLE_STATUS
return False
@retry(
retry=retry_if_exception(_is_retryable),
stop=stop_after_attempt(5),
wait=wait_exponential_jitter(initial=1, max=30), # ~1s, 2s, 4s, 8s... plus random jitter
before_sleep=before_sleep_log(log, logging.WARNING),
reraise=True,
)
def get_with_retries(client: httpx.Client, url: str, **kwargs) -> httpx.Response:
r = client.get(url, **kwargs)
r.raise_for_status()
return r
Jitter matters: if a hundred clients fail at the same moment and all retry after exactly 2 seconds, they hit the recovering server together again. Randomized delays spread them out.
Respect Retry-After¶
import time
def get_respecting_rate_limit(client: httpx.Client, url: str) -> httpx.Response:
for _ in range(5):
r = client.get(url)
if r.status_code != 429:
r.raise_for_status()
return r
wait = int(r.headers.get("Retry-After", "5"))
log.warning("rate limited, sleeping %ss", wait)
time.sleep(min(wait, 60))
raise RuntimeError(f"still rate limited after 5 attempts: {url}")
Many APIs also report remaining quota in headers such as X-RateLimit-Remaining. Slow down before you hit the limit when running bulk jobs.
Pagination¶
APIs return large collections in pages. Three common styles:
| Style | How you get the next page |
|---|---|
| Link header (GitHub) | Follow Link: <...>; rel="next" |
| Cursor / token (most modern APIs, AWS) | Pass back a next_cursor or nextToken value |
| Offset / page number | Increment page or offset until results are empty |
from collections.abc import Iterator
def iter_link_pages(client: httpx.Client, url: str, params: dict | None = None) -> Iterator[dict]:
"""Yield items from every page of a Link-header-paginated API."""
next_url: str | None = url
while next_url:
r = get_with_retries(client, next_url, params=params)
yield from r.json()
next_url = r.links.get("next", {}).get("url")
params = None # the next URL already includes query parameters
def iter_cursor_pages(client: httpx.Client, url: str) -> Iterator[dict]:
cursor = None
while True:
params = {"limit": 100, **({"cursor": cursor} if cursor else {})}
data = get_with_retries(client, url, params=params).json()
yield from data["items"]
cursor = data.get("next_cursor")
if not cursor:
break
Generators keep memory flat — you can process 100,000 items without holding them all at once.
with httpx.Client(base_url="https://api.github.com", headers=headers, timeout=10) as client:
stale = [
repo["full_name"]
for repo in iter_link_pages(client, "/orgs/acme/repos", {"per_page": 100})
if not repo["archived"] and repo["pushed_at"] < "2025-01-01"
]
Authentication¶
import os
# Bearer token from the environment
client = httpx.Client(headers={"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}"})
# Basic auth
client = httpx.Client(auth=(os.environ["API_USER"], os.environ["API_PASSWORD"]))
# Mutual TLS with a client certificate
client = httpx.Client(cert=("/etc/opsctl/client.crt", "/etc/opsctl/client.key"))
# A private certificate authority — verify against it, never disable verification
client = httpx.Client(verify="/etc/ssl/certs/internal-ca.pem")
Never use verify=False
Disabling TLS verification lets anyone on the network path read and modify the traffic, including your tokens. Point verify at the right CA bundle instead.
Load tokens from environment variables injected by CI or a secrets manager — never hard-code them or commit them.
Idempotent Writes¶
When creating resources, make retries safe:
import uuid
key = str(uuid.uuid4())
r = client.post(
"/v1/deployments",
json={"service": "orders-api", "version": "2.14.0"},
headers={"Idempotency-Key": key}, # the server returns the original result for a repeated key
)
If the API doesn't support idempotency keys, check whether the resource already exists before creating it, and don't retry POST automatically.
Async for Many Concurrent Requests¶
For checking hundreds of endpoints, httpx.AsyncClient runs requests concurrently. Always bound concurrency:
import asyncio
import httpx
async def check_all(urls: list[str], concurrency: int = 20) -> dict[str, int | str]:
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(timeout=5) as client:
async def one(url: str) -> tuple[str, int | str]:
async with sem:
try:
r = await client.get(url)
return url, r.status_code
except httpx.TransportError as exc:
return url, type(exc).__name__
return dict(await asyncio.gather(*(one(u) for u in urls)))
results = asyncio.run(check_all(urls))
Putting It Together: The Health Check Module¶
This is the module behind opsctl check from the previous chapter. Retries and backoff are parameters, so tests can turn the waiting off.
import logging
import time
from dataclasses import dataclass
import httpx
from tenacity import (
RetryError,
Retrying,
retry_if_exception,
stop_after_attempt,
wait_exponential_jitter,
wait_none,
)
log = logging.getLogger(__name__)
RETRYABLE_STATUS = {429, 502, 503, 504}
@dataclass(frozen=True)
class HealthResult:
url: str
ok: bool
status_code: int | None
elapsed_ms: float
def _is_retryable(exc: BaseException) -> bool:
if isinstance(exc, httpx.TransportError):
return True
return isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in RETRYABLE_STATUS
def check(
url: str, *, timeout: float = 5.0, attempts: int = 3, backoff: float = 0.5
) -> HealthResult:
"""GET a health endpoint, retrying transient failures. Never raises."""
start = time.monotonic()
status: int | None = None
retrying = Retrying(
retry=retry_if_exception(_is_retryable),
stop=stop_after_attempt(attempts),
wait=wait_exponential_jitter(initial=backoff, max=10) if backoff else wait_none(),
)
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
for attempt in retrying:
with attempt:
response = client.get(url)
status = response.status_code
response.raise_for_status()
ok = True
except (RetryError, httpx.HTTPError) as exc:
log.warning("health check failed for %s: %s", url, exc)
ok = False
return HealthResult(url, ok, status, (time.monotonic() - start) * 1000)
Common Mistakes¶
- No timeout, so a single unresponsive endpoint hangs a cron job indefinitely.
- Retrying every error, including
401and404, and hammering an API that will never succeed. - Retrying without backoff and jitter, turning a brief outage into a self-inflicted overload.
- Reading only the first page of results and silently processing incomplete data.
verify=Falseto get past a certificate error.- Creating a new client for every request, losing connection reuse.
- Logging full responses or headers that contain tokens.
Interview Questions¶
- Which HTTP failures should an automation script retry, and which should it not?
- Why add jitter to exponential backoff?
- How do you handle an API that returns
429 Too Many Requests? - How would you safely retry a request that creates a resource?
- Why reuse a client object instead of calling
httpx.getrepeatedly?
Next¶
Continue to AWS Automation With boto3.