Async HTTP clients
Many requests at once with asyncio and httpx: limits, timeouts and retries.
Create a free account Try part of it here
Why it matters
A program that checks prices for 200 products, or pulls pages from a dozen services, spends almost all its time waiting for answers. Asking one at a time means waiting 200 times in a row. Asking concurrently means waiting roughly once. That speed comes with duties: don't flood the service, don't hang forever on one slow reply, and retry sensibly when something fails. This lesson builds a client that does all four, using asyncio from the concurrency lesson and httpx, the async-capable cousin of requests.
What you'll be able to do
- Send many HTTP requests concurrently with asyncio and one shared httpx.AsyncClient
- Cap how many requests run at once with a semaphore
- Set timeouts and keep one failure from sinking a whole batch
- Retry only the failures worth retrying, with exponential backoff
What it covers
- Waiting for many things at once
- One client for the whole batch
- Limits: be a good client
- Timeouts and failures
- Worked example: errors as values
- Async code outside the academy
Waiting for many things at once
In an async def function, await marks the places where the program waits. While one request waits for its reply, the event loop runs others. asyncio.gather starts several awaitables together and returns their results in the order you gave them, whichever finishes first.
The cells in this lesson talk to a pretend price service built with httpx.MockTransport: a function that receives each request and returns a response, here after a 0.2-second pause, like a real network call. The same tool makes async code easy to test, because the "server" is just Python.
import asyncio, time import httpx async def price_service(request): await asyncio.sleep(0.2) # the network and the server's work sku = request.url.path.rsplit("/", 1)[-1] return httpx.Response(200, json={"sku": sku, "price": int(sku[-3:]) / 4}) SKUS = [f"TE-{n}" for n in range(100, 110)] async def one_by_one(client): return [(await client.get(f"/prices/{s}")).json()["price"] for s in SKUS] async def all_at_once(client): replies = await asyncio.gather(*(client.get(f"/prices/{s}") for s in SKUS)) return [r.json()["price"] for r in replies] async def main(): transport = httpx.MockTransport(price_service) async with httpx.AsyncClient(transport=transport, base_url="https://prices.example") as client: for fetch in (one_by_one, all_at_once): start = time.perf_counter() prices = await fetch(client) print(f"{fetch.__name__}: {time.perf_counter() - start:.2f}s", prices[:4]) asyncio.run(main())
Ten requests one after another take ten waits; together they take about one. With 200 products the difference is 40 seconds against a fraction of one.
Note:
asyncio.runneeds a current browser (one with WebAssembly JSPI, like recent Chrome, Edge and Safari). If a cell here stops with an error about asyncio, run it on your own computer or update the browser.
import asyncio async def answer(value, delay): await asyncio.sleep(delay) return value async def main(): print(await asyncio.gather(answer("slow", 0.03), answer("fast", 0.01))) asyncio.run(main())
Show the answer
['slow', 'fast']
gather returns results in the order the awaitables were passed, not the order they finished, so it prints ['slow', 'fast'].
One client for the whole batch
Create one httpx.AsyncClient and share it, inside async with so its connections close at the end. A client keeps connections open between requests (a connection pool), so the second request to the same server skips the slow setup of a new connection and TLS handshake. Creating a client for every request throws that away, and with hundreds of requests it can run the machine out of open connections.
Give the client a base_url and default settings once:
client = httpx.AsyncClient(
base_url="https://api.example.com/v1",
headers={"Authorization": f"Bearer {token}"},
timeout=httpx.Timeout(10.0, connect=3.0),
limits=httpx.Limits(max_connections=10),
)
timeout matters more than it looks. httpx gives up after 5 seconds by default (requests waits forever unless told otherwise), and a program without a timeout can hang for hours on one stuck connection. max_connections caps the pool, which is one way to stop a batch from flooding a server.
Limits: be a good client
Starting a thousand requests at once is rude to the server and usually counterproductive: it answers 429 Too Many Requests, or slows down for everyone. Most APIs document a limit, like "at most 5 requests at a time". An asyncio.Semaphore enforces one in your own code: it holds a number of permits, and async with sem: waits until a permit is free.
import asyncio, time in_flight = 0 busiest = 0 async def fetch(i, sem): global in_flight, busiest async with sem: in_flight += 1 busiest = max(busiest, in_flight) await asyncio.sleep(0.1) # the request in_flight -= 1 return i * i async def main(): sem = asyncio.Semaphore(3) start = time.perf_counter() results = await asyncio.gather(*(fetch(i, sem) for i in range(12))) print(results) print(f"at most {busiest} at once, {time.perf_counter() - start:.2f}s") asyncio.run(main())
Twelve requests of 0.1 seconds, three at a time, take about 0.4 seconds: four rounds. All twelve tasks start at once, but only three hold a permit; the rest wait at async with sem without using any resources. Create the semaphore inside the async code (here in main), so it belongs to the running event loop.
Async speeds up waiting, not working. For heavy computation, use processes (the concurrency lesson).
Timeouts and failures
In a batch of 200 requests, something will go wrong: a timeout, a 500, a refused connection. Decide what one failure should do to the rest:
asyncio.gather(*tasks)raises the first exception, and you lose the other results.asyncio.gather(*tasks, return_exceptions=True)puts exceptions in the result list in place of values, so you can report each failure and keep every success.async with asyncio.TaskGroup() as tg:(Python 3.11 and later) cancels the remaining tasks when one fails. That's right when the results only make sense together, like the pieces of one page.
Often the clearest design is a small function that fetches one item and never raises: it catches the errors it expects and returns a value or an error message. Then a plain gather collects everything.
For a deadline on the whole batch, wrap it in async with asyncio.timeout(30):, which cancels whatever is still running after 30 seconds and raises TimeoutError.
Retries with backoff
Some failures are temporary, and the same request a moment later succeeds. Retry timeouts, connection errors, 429 and 502, 503, 504. Don't retry most other 4xx errors: a 404 or a 422 will fail the same way every time. Be careful with POST: retrying a payment that actually went through charges twice, unless the API supports idempotency keys (the reliability lesson).
Wait longer before each retry, exponential backoff: 0.5 s, then 1 s, then 2 s, so a struggling server gets room to recover. Add a little random jitter, so a thousand clients that failed together don't all retry in the same instant. If a 429 or 503 reply carries a Retry-After header, wait that long instead. And always cap the number of attempts.
Worked example: errors as values
A status checker for five services. One is slow, one is down, one doesn't exist. Each check returns a line of text, so a single failure never hides the other results.
import asyncio import httpx async def services(request): name = request.url.host.split(".")[0] if name == "slow": await asyncio.sleep(1) if name == "down": return httpx.Response(503) if name == "gone": raise httpx.ConnectError("name not resolved", request=request) return httpx.Response(200, json={"status": "ok"}) async def check(client, name): try: async with asyncio.timeout(0.3): reply = await client.get(f"https://{name}.example/health") reply.raise_for_status() return f"{name}: up" except (TimeoutError, httpx.TimeoutException): return f"{name}: too slow (no answer in 0.3s)" except httpx.HTTPStatusError as e: return f"{name}: answered {e.response.status_code}" except httpx.RequestError as e: return f"{name}: unreachable ({e})" async def main(): async with httpx.AsyncClient(transport=httpx.MockTransport(services)) as client: names = ["shop", "slow", "down", "gone", "search"] async with asyncio.timeout(5): for line in await asyncio.gather(*(check(client, n) for n in names)): print(line) asyncio.run(main())
check gives each service 0.3 seconds with asyncio.timeout, which works whatever the transport. On a real network, httpx's own timeout setting raises httpx.TimeoutException instead, so the except catches both. raise_for_status() turns the 503 into an HTTPStatusError, which carries the response. ConnectError is one kind of RequestError, so the general except httpx.RequestError comes last. Because every check returns a string, gather always gets five results, in the order of names, and the whole batch has a five-second deadline as a safety net.
Async code outside the academy
The cells here end with asyncio.run(main()): one call that starts the event loop, runs main and closes the loop. A program should call it once, at the top. Inside a Jupyter notebook a loop is already running, so there you write await main() directly in a cell instead.
In a FastAPI app, the event loop belongs to the server, so an async def endpoint simply awaits an async client, which the app usually creates once at startup and closes at shutdown. And when a library you need has only blocking functions, await asyncio.to_thread(func, ...) runs it in a thread so the loop keeps going.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why share one httpx.AsyncClient across all the requests in a batch?