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.
- Explain what
timeout=does and recognise theURLErrorwhose reason isTimeoutError - 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
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.
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.
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.
- Pass
timeout=on every request; a timeout raisesURLErrorwith aTimeoutErrorreason (orTimeoutErrorwhile reading). - Retry timeouts, connection errors, 429 and 5xx; raise 4xx at once. Test
HTTPErrorbeforeURLError. - Limit the attempts, wait longer each time (or as long as
Retry-Aftersays), and raise the last error. - Next: wrapping requests, pagination and errors in a small client class.