runtime
154 / 250
lesson 154 HTTP & API Clients · advanced

Timeouts and retries

Networks fail in passing: a server is busy, a response is slow, a connection drops. A timeout stops a request from waiting forever; a retry with growing waits gets past a short failure without hammering the server.

Simulated: this runs against results recorded on real Python.

You'll be able to
  • Explain what timeout= does and recognise the URLError whose reason is TimeoutError
  • Tell temporary failures (timeouts, 429, 502, 503, 504) from permanent ones (400, 401, 404)
  • Write a retry loop with a limit and exponential backoff, honouring Retry-After
The idea

Some failures last a moment. A server under load answers 503 Service Unavailable, an API that thinks you're too fast answers 429 Too Many Requests (often with a Retry-After header saying how many seconds to wait), and sometimes no answer comes at all.

timeout= sets how many seconds urllib waits for the connection and for each read. When it runs out, open raises `URLError` whose reason is a TimeoutError (a read that stalls later raises TimeoutError itself). The replay in this course compares each saved response's real duration with your timeout and raises the same error, without actually waiting.

Run each example to see what it prints. Running examples doesn't count toward progress.

example-1.py · A slow answer and a timeout
import io
import json
from email.message import Message
from http import HTTPStatus
from urllib.error import URLError
from urllib.request import BaseHandler, Request, build_opener
from urllib.response import addinfourl

class Replay(BaseHandler):
    """Answers https requests from saved copies of real API responses."""
    handler_order = 100

    def __init__(self, saved):
        self.saved = saved

    def https_open(self, request):
        status, body, seconds = self.saved[request.full_url]
        if seconds > request.timeout:
            raise URLError(TimeoutError("timed out"))
        headers = Message()
        headers["Content-Type"] = "application/json"
        response = addinfourl(io.BytesIO(json.dumps(body).encode()), headers, request.full_url, status)
        response.msg = HTTPStatus(status).phrase
        return response

url = "https://earthquake.usgs.gov/fdsnws/event/1/count?format=geojson&starttime=2025-01-01&endtime=2025-07-01"
opener = build_opener(Replay({url: (200, {"count": 12855, "maxAllowed": 20000}, 3.2)}))

for timeout in [2, 5]:
    try:
        with opener.open(url, timeout=timeout) as response:
            print(timeout, "s: count =", json.loads(response.read())["count"])
    except URLError as err:
        print(timeout, "s:", type(err.reason).__name__, err.reason)

Should you try again? Only when the failure might go away: a timeout, a refused or dropped connection, 429, 500, 502, 503, 504. A 400, 401, 403 or 404 will fail the same way every time, so retrying only adds load.

Wait between attempts, longer each time: 1 s, 2 s, 4 s (exponential backoff), so a struggling server gets room to recover. Limit the attempts, and when they are used up, raise the last error so the caller knows. A function that sleeps is slow to test, so take the sleep function as a parameter (dependency injection, the Advanced OOP & Design module): time.sleep in production, something that just records the waits in a test.

example-2.py · Retry a busy server with backoff
import io
import json
from email.message import Message
from http import HTTPStatus
from urllib.request import BaseHandler, Request, build_opener
from urllib.response import addinfourl

class Replay(BaseHandler):
    """Answers https requests from saved copies of real API responses."""
    handler_order = 100

    def __init__(self, saved):
        self.saved = saved

    def https_open(self, request):
        status, body = self.saved[request.full_url].pop(0)
        headers = Message()
        headers["Content-Type"] = "application/json"
        response = addinfourl(io.BytesIO(json.dumps(body).encode()), headers, request.full_url, status)
        response.msg = HTTPStatus(status).phrase
        return response
import time
from urllib.error import HTTPError

RETRY_STATUSES = [429, 500, 502, 503, 504]

def get_json_with_retries(opener, url, attempts=4, sleep=time.sleep):
    wait = 1
    for attempt in range(1, attempts + 1):
        try:
            with opener.open(url, timeout=10) as response:
                return json.loads(response.read())
        except HTTPError as err:
            if err.code not in RETRY_STATUSES or attempt == attempts:
                raise
        sleep(wait)
        wait *= 2

url = "https://api.openalex.org/works/W3036167779"
saved = {url: [(503, {"error": "busy"}), (503, {"error": "busy"}), (200, {"cited_by_count": 8702})]}
waits = []
work = get_json_with_retries(build_opener(Replay(saved)), url, sleep=waits.append)
print(work["cited_by_count"], "after waiting", waits)

When to use it: every program that talks to a network service. Mistakes to avoid: no timeout; retrying 4xx errors; retrying forever or without waiting; sleeping with no limit on the total time; and catching URLError before HTTPError (an HTTPError *is* a URLError, so the first except would swallow every status). Retry only requests that are safe to repeat: a GET is; a POST that charges a card may not be.

Review
  • Pass timeout= on every request; a timeout raises URLError with a TimeoutError reason (or TimeoutError while reading).
  • Retry timeouts, connection errors, 429 and 5xx; raise 4xx at once. Test HTTPError before URLError.
  • Limit the attempts, wait longer each time (or as long as Retry-After says), and raise the last error.
  • Next: wrapping requests, pagination and errors in a small client class.
Practise this

Loading your progress…

To master this lesson, solve both challenges without hints or the answer, and pass the concept check.

Timeouts and retries — HTTP & API Clients · Runtime