Index Ai Academy
Phase 8 · APIs · Web apps

Async HTTP clients

Many requests at once with asyncio and httpx: limits, timeouts and retries.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Waiting for many things at once
  2. One client for the whole batch
  3. Limits: be a good client
  4. Timeouts and failures
  5. Worked example: errors as values
  6. 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.run needs 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.

Quick check
What does this print?
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())
  1. fast slow
  2. ['fast', 'slow']
  3. ['slow', 'fast']
  4. It depends on timing
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.

Try it now

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

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())
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why share one httpx.AsyncClient across all the requests in a batch?

  1. It reuses open connections, skipping setup for each request, and keeps settings in one place
  2. httpx allows only one client per program
  3. Each client can send only one request
  4. It makes requests run one at a time

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson