HTTP Clients and Talking to APIs
Sessions and connection reuse, timeouts you must always set, retries with backoff on the right status codes, pagination, and reading an API error instead of swallowing it.
Sessions and connection reuse, timeouts you must always set, retries with backoff on the right status codes, pagination, and reading an API error instead of swallowing it.
The sync job ran for eleven hours last night and processed nothing. No error, no traceback, no log line after the first one. It was waiting on a single HTTP request that never returned, because the connection was accepted and then nothing came back, and nobody had set a timeout.
Talking to another service over a network means accepting that it can be slow, absent, rate-limiting you, or lying about success. By the end of this lesson you will make requests that cannot hang, retry the failures worth retrying, page through results without holding them all, and read an error instead of swallowing it.
import httpx
response = httpx.get(
"https://api.example.com/albums/7",
timeout=10.0,
headers={"Accept": "application/json"},
)
response.raise_for_status()
album = response.json()httpx and requests share almost the same interface; the
examples here work with either, and httpx additionally
supports async, which the advanced course covers.
Two lines carry the weight.
timeout=10.0 is not optional. Without it, httpx and
requests differ in their defaults and neither is a promise —
the eleven-hour job above is what "no timeout" means in
practice. A timeout is a product decision: how long is this
worth waiting for?
raise_for_status() turns a 404 or a 500 into an
exception. Without it, response.json() on an error page raises
something about invalid JSON, or worse, parses an error body
into a dictionary your code treats as data.
More precise timeouts are available and worth using once you care:
timeout = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)Connecting should be fast. Reading a large response legitimately is not. One number for both means choosing badly for one of them.
Bad — a fresh connection per request.
for album_id in album_ids: # 500 albums
response = httpx.get(f"{BASE}/albums/{album_id}", timeout=10)
process(response.json())Good — one client, reused.
with httpx.Client(
base_url=BASE,
timeout=10.0,
headers={"Authorization": f"Bearer {token}"},
) as client:
for album_id in album_ids:
response = client.get(f"/albums/{album_id}")
response.raise_for_status()
process(response.json())Each module-level get opens a new TCP connection and, over
HTTPS, performs a fresh TLS handshake — several round trips
before a single byte of your request is sent. Over five hundred
albums that is most of the runtime, spent on setup rather than
work. A Client keeps a connection pool and reuses them.
The client is also where cross-cutting settings belong. The
Authorization header is set once instead of at five hundred
call sites, so it cannot be forgotten at one of them.
The rule from the AI course applies to every network call: ask whether doing this again could produce a different result.
Worth retrying — connection errors, timeouts, 429, 502, 503, 504
The request was fine and the other side was busy, restarting or unreachable. Later is a genuinely different moment.
Never retry — 400, 401, 403, 404, 422
Your request is the problem, and sending it again changes
nothing. Five attempts at a 401 give you five failures and,
on some services, a lockout.
Careful — 500
Sometimes a transient fault, sometimes a bug that will fail identically forever. Once or twice, then give up.
And whatever you retry, wait first. Retrying a 429 immediately
makes the rate limiting worse rather than better.
import time
import httpx
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
def get_with_retry(client, url, *, attempts=4):
for attempt in range(1, attempts + 1):
try:
response = client.get(url)
except (httpx.TimeoutException, httpx.ConnectError) as error:
if attempt == attempts:
raise
last = error
else:
if response.status_code not in RETRYABLE_STATUS:
response.raise_for_status()
return response
if attempt == attempts:
response.raise_for_status()
last = response
delay = _delay_for(last, attempt)
log.warning("retrying %s in %.1fs (attempt %d)", url, delay, attempt)
time.sleep(delay)def _delay_for(last, attempt):
if isinstance(last, httpx.Response):
retry_after = last.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
return float(retry_after) # the server told you
return min(2 ** (attempt - 1), 30) + random.uniform(0, 1)Two details do the real work. Retry-After is an
instruction, and honouring it is both more correct and more
polite than guessing. And the jitter — a random fraction of
a second — stops a hundred clients that failed together from
retrying in lockstep and failing together again.
The tenacity library implements all of this if you would
rather not.
Most collection endpoints return one page and a way to ask for the next. A generator is the natural shape, from the laziness lesson:
def iter_photos(client, album_id):
url = f"/albums/{album_id}/photos"
while url:
response = client.get(url)
response.raise_for_status()
payload = response.json()
yield from payload["items"]
url = payload.get("next") # None on the last pagefor photo in iter_photos(client, 7):
process(photo) # starts immediately, holds one pageThe caller writes an ordinary loop and never learns that paging happened. Work starts on the first page rather than after the last, and memory holds one page rather than all of them.
Two other paging styles you will meet: an offset (?page=3),
which is simple and can skip or repeat items if the data changes
mid-scan; and a cursor, which is what the example above uses and
what well-designed APIs provide.
An HTTP failure usually explains itself, and the explanation is in the body:
try:
response = client.post("/photos", json=payload)
response.raise_for_status()
except httpx.HTTPStatusError as error:
log.error(
"POST /photos failed: %d %s",
error.response.status_code,
error.response.text[:500],
)
raise PhotoUploadError(
f"upload rejected ({error.response.status_code})"
) from error
except httpx.RequestError as error:
raise PhotoUploadError(f"could not reach the API: {error}") from errorThe server answered, and said no.
There is a body, and it usually says exactly what was wrong. The fix is normally in your request.
You never got an answer at all.
DNS, connection, timeout. Nothing to read, and no way to know whether the server did the work before the wire went quiet.
Logging error.response.text is the line that ends
investigations. A 422 with a body saying {"detail": "size must be a positive integer"} tells you the bug; the status code
alone tells you to go looking. Truncate it, because an error
page can be a megabyte of HTML.
client.post("/photos", json={"name": name, "size": size}) # JSON body
client.post("/upload", files={"file": path.open("rb")}) # multipart
client.get("/photos", params={"album": 7, "since": "2026-01-01"})params= builds the query string with correct escaping. Never
assemble one by hand — a value containing & or a space
silently produces different parameters than you intended.
And the rule from the logging lesson applies with force here:
log.debug("request headers: %r", request.headers) # neverHeaders carry the Authorization token. Log the URL, the
method, the status and the timing — never the whole request or
response object.
EVERY REQUEST
timeout=10.0 never omit it - it is a product decision
response.raise_for_status()
httpx.Timeout(connect=5, read=30) connect fast, read slow
REUSE
with httpx.Client(base_url=..., headers=..., timeout=...) as c:
one connection pool, one place for auth headers
per-request get() means a new TLS handshake every time
RETRY
yes connect errors, timeouts, 429, 502, 503, 504
no 400 401 403 404 422 - the request is the problem
care 500 - once or twice
honour Retry-After when the server sends it
exponential backoff + jitter, capped
never retry a POST blindly - idempotency key, or do not
PAGING
a generator: while url: ... yield from items; url = next
callers write an ordinary for loop
starts immediately, holds one page
ERRORS
HTTPStatusError answered, said no -> error.response
RequestError never answered -> DNS, connect, timeout
log error.response.text[:500] - the body has the reason
SENDING
json={...} files={...} params={...}
never build a query string by hand
NEVER LOG
headers, whole request/response objects - the token is in there
do log: method, url, status, elapsed
ALSO
User-Agent naming your app and a contact addressYour programs can now talk to other services without hanging, retry what is worth retrying at a rate that does not make things worse, walk a paginated collection lazily, and report failures with the detail that ends the investigation rather than starting it.
Next is Configuration and Secrets, which is where the token
in that Authorization header comes from. It is the last piece
of making a program deployable: values that change per
environment, kept out of your repository, and validated at
startup rather than discovered at midnight.
Before you move on, find every HTTP call in your code and check
two things — that it has a timeout, and that something calls
raise_for_status. Those two omissions account for most
mysterious hangs and most "the API returned success but the data
is wrong" bugs, and finding them takes minutes.