Async HTTP Requests

Deep dive · part of Python Asyncio

Pure asyncio doesn't ship with an HTTP client, but aiohttp and httpx do. Fetching many URLs concurrently is one of asyncio's killer use cases: hundreds of concurrent requests on a single thread.

Fetching hundreds of URLs sequentially wastes wall-clock time waiting on network latency. asyncio with httpx or aiohttp overlaps waits on a single thread: while one request awaits bytes, others progress. This is the canonical asyncio win for I/O-bound workloads.

You still need timeouts, connection limits, and backoff because concurrency amplifies failure modes—rate limits, file descriptor exhaustion, and thundering herds on flaky APIs all appear faster when you parallelize.

asyncio.gather schedules multiple coroutines and collects results (or exceptions with return_exceptions=True).

AsyncClient context manager pools connections; reuse one client per batch, not per URL.

Semaphore limits in-flight requests (bounded concurrency) protecting remote servers and local fds.

stream() and aiter_bytes() process large downloads without loading entire bodies into RAM.

Timeouts belong on each request (timeout=10) and optionally on the whole gather via asyncio.wait_for.

SSL, redirects, and auth mirror sync httpx/requests APIs with async method names.

Choose httpx for API symmetry with requests and HTTP/2 support; aiohttp is mature for server and client with slightly different session APIs. Both require asyncio.run(main()) or an existing loop in Jupyter (nest_asyncio in notebooks).

For CPU-bound post-processing of responses, offload to asyncio.to_thread or ProcessPoolExecutor—async only helps waiting, not parsing huge JSON on one core.

Retry policies combine Semaphore with tenacity or custom loops; jitter backoff avoids synchronized retries after outages.

Per-host semaphores keyed by netloc improve crawl throughput when batches mix many domains.

Separate semaphores per netloc when crawling mixed domains to avoid one slow host blocking all.

Creating a new AsyncClient per URL, destroying connection pooling benefits.

Unbounded gather on millions of URLs without semaphores, hitting EMFILE or bans.

Calling blocking requests.get inside async def, freezing the event loop.

Ignoring status codes and treating 500 responses as success without raise_for_status.

Reuse one AsyncClient per logical batch and set limits=Limits(max_connections=20).

Wrap gather in asyncio.wait_for for global deadlines on scrape jobs.

Log per-host failure rates and circuit-break hot domains.

Test with respx or pytest-httpx mocking async transports.

Re-read the examples below with these ideas in mind; change variable names and inputs to match your own project.

The program below demonstrates fetch many urls. Read the comments on each line, run the code, then change names or values to see how the output shifts.

# Example: Fetch many URLs
# Run in the REPL or save as a .py file and execute with python.
import asyncio, httpx

async def fetch(client, url):
    r = await client.get(url, timeout=10)
    return url, r.status_code, len(r.content)

async def main(urls):
    async with httpx.AsyncClient() as c:
        return await asyncio.gather(*(fetch(c, u) for u in urls))

urls = ["https://example.com", "https://httpbin.org/get"] * 5
print(asyncio.run(main(urls)))

This sample walks through bounded concurrency in a small, runnable script. Paste it into the REPL or save it as a .py file before you continue to the next block.

# Example: Bounded concurrency
# Run in the REPL or save as a .py file and execute with python.
import asyncio, httpx

async def worker(sem, client, url):
    async with sem:
        r = await client.get(url, timeout=10)
        return url, r.status_code

async def main(urls, concurrency=5):
    sem = asyncio.Semaphore(concurrency)
    async with httpx.AsyncClient() as c:
        return await asyncio.gather(*(worker(sem, c, u) for u in urls))

print(asyncio.run(main(["https://example.com"] * 20)))

Here is a hands-on illustration of streaming response. Follow the inline comments first; only then execute the snippet and compare the result with what you expected.

# Example: Streaming response
# Run in the REPL or save as a .py file and execute with python.
import asyncio, httpx

async def stream(url):
    async with httpx.AsyncClient() as c:
        async with c.stream("GET", url) as r:
            async for chunk in r.aiter_bytes(chunk_size=1024):
                print("got", len(chunk), "bytes")

asyncio.run(stream("https://httpbin.org/bytes/4096"))

The program below demonstrates gather urls. Read the comments on each line, run the code, then change names or values to see how the output shifts.

# httpx AsyncClient fetches many URLs concurrently
import asyncio  # event loop
import httpx  # async HTTP (pip install httpx)

async def fetch(client, url):  # single GET
    r = await client.get(url, timeout=10)  # await response
    return url, r.status_code  # summary tuple

async def main():  # runner
    urls = ["https://example.com", "https://httpbin.org/get"]  # targets
    async with httpx.AsyncClient() as client:  # shared client
        out = await asyncio.gather(*(fetch(client, u) for u in urls))  # parallel
    print(out)  # list of tuples

asyncio.run(main())  # execute

This sample walks through semaphore limit in a small, runnable script. Paste it into the REPL or save it as a .py file before you continue to the next block.

# Semaphore caps concurrent in-flight requests
import asyncio, httpx  # deps

async def worker(sem, client, url):  # bounded worker
    async with sem:  # acquire slot
        r = await client.get(url, timeout=10)  # fetch
        return url, r.status_code  # result

async def main():  # orchestrator
    sem = asyncio.Semaphore(2)  # max two concurrent
    urls = ["https://example.com"] * 5  # repeated URL
    async with httpx.AsyncClient() as client:  # client
        print(await asyncio.gather(*(worker(sem, client, u) for u in urls)))  # run

asyncio.run(main())  # start

« back to Python Asyncio All tutorials