Async HTTP Calls and Blocking Code
30 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)A web request is mostly waiting. That makes it the standard use of asyncio: one thread can keep hundreds of requests in flight.
Real HTTP with httpx (not run here)
Async HTTP client is a library object whose request methods are coroutines. It sends a request, awaits the reply without blocking the loop, and lets other tasks run meanwhile.
Async HTTP libraries such as httpx and aiohttp let a coroutine await a web reply while other requests proceed.
# Not run here: needs the httpx package and a network.import asyncioimport httpx
async def fetch(client, url): response = await client.get(url, timeout=5) return response.status_code
async def main(urls): async with httpx.AsyncClient() as client: return await asyncio.gather(*(fetch(client, u) for u in urls))The code below needs the httpx package and a network, so it is shown but not run.
One client is shared by all requests, so connections are reused. The rest of this lesson uses a fake client, so every example here runs the same way on any machine.
A fake client for practice
Async context manager is an object used with async with. Its enter and exit steps can await, for example to open and close a network connection cleanly.
A real client is used with async with, which can await when it opens and closes. We can build the same shape with __aenter__ and __aexit__.
import asyncio
class FakeClient: async def __aenter__(self): return self
async def __aexit__(self, *exc): return False
async def get(self, url): await asyncio.sleep(0.05) return {"url": url, "status": 200}Our fake get sleeps instead of calling a network.
Example 1 · One request with a fake client
import asyncio
class FakeClient: async def __aenter__(self): return self
async def __aexit__(self, *exc): return False
async def get(self, url): await asyncio.sleep(0.5 if "slow" in url else 0.02) return {"url": url, "status": 200}
async def main(): async with FakeClient() as client: reply = await client.get("/orders/101") print(reply)
asyncio.run(main()){'url': '/orders/101', 'status': 200}What this code does
async withopens the client and closes it at the end of the block.getawaits a short sleep in place of the network and returns a dict like a real reply.
Example 2 · An async generator for pages
import asyncio
async def pages(total, size): for start in range(0, total, size): await asyncio.sleep(0.01) # one request per page yield list(range(start, min(start + size, total)))
async def main(): async for page in pages(7, 3): print(page)
asyncio.run(main())[0, 1, 2] [3, 4, 5] [6]
What this code does
yieldinsideasync defmakes an async generator, andasync forreads it.- Each page is awaited before the next one starts, which is right when the next page depends on the last.
async for is the matching loop for sources that produce items by awaiting, such as pages of results.
Many requests, with a limit and a timeout
Concurrency limit is a cap on how many requests run at the same time, usually set with a Semaphore. It protects the server and your own memory and sockets.
Send requests with gather, but cap how many run at once with a semaphore, and give each its own timeout so one slow URL cannot hold the rest.
async def fetch_one(client, sem, url): async with sem: try: reply = await asyncio.wait_for(client.get(url), 0.1) return url, reply["status"] except TimeoutError: return url, "timeout"Turn failures into plain values.
Example 3 · Many requests with gather
import asyncio
class FakeClient: async def __aenter__(self): return self
async def __aexit__(self, *exc): return False
async def get(self, url): await asyncio.sleep(0.5 if "slow" in url else 0.02) return {"url": url, "status": 200}
async def main(): urls = ["/a", "/b", "/c", "/d"] async with FakeClient() as client: replies = await asyncio.gather(*(client.get(u) for u in urls)) print([r["url"] for r in replies])
asyncio.run(main())['/a', '/b', '/c', '/d']
What this code does
- All four requests wait together, so the whole run takes about one request.
- The replies come back in the order of the URLs.
Example 4 · Limit, timeout and failures as values
import asyncio
class FakeClient: async def __aenter__(self): return self
async def __aexit__(self, *exc): return False
async def get(self, url): await asyncio.sleep(0.5 if "slow" in url else 0.02) return {"url": url, "status": 200}
async def fetch_one(client, sem, url): async with sem: try: reply = await asyncio.wait_for(client.get(url), 0.1) return url, reply["status"] except TimeoutError: return url, "timeout"
async def main(): urls = ["/a", "/slow", "/b"] sem = asyncio.Semaphore(2) async with FakeClient() as client: results = await asyncio.gather(*(fetch_one(client, sem, u) for u in urls)) print(dict(results))
asyncio.run(main()){'/a': 200, '/slow': 'timeout', '/b': 200}What this code does
- The
/slowURL takes longer than the 0.1 s limit, so it becomes"timeout"instead of an error. - At most two requests run at once.
gatherkeeps order, so the dict is built in the order of the URLs.
gather keeps the input order, so the answers line up with the URLs.
Blocking libraries: asyncio.to_thread
asyncio.to_thread runs an ordinary blocking function in a worker thread and returns an awaitable for its result, so the event loop is not blocked.
Many useful libraries are not async. Calling one directly would block the loop. asyncio.to_thread(func, args) runs it in a worker thread and lets you await the result.
import asyncioimport time
def read_report(n): time.sleep(0.05) # a blocking call return n * 2
async def main(): print(await asyncio.to_thread(read_report, 21))The loop stays free for other tasks.
Example 5 · Blocking calls moved to threads
import asyncioimport time
def read_report(n): time.sleep(0.05) # blocking, not async return n * 2
async def main(): results = await asyncio.gather(*(asyncio.to_thread(read_report, n) for n in (1, 2, 3))) print(results)
asyncio.run(main())[2, 4, 6]
What this code does
- Each call runs in its own worker thread, so the three waits overlap.
- The event loop is free during the waits, and
gatherkeeps the order.
This is the bridge between old blocking code and an async program. For heavy calculation use a process pool with loop.run_in_executor instead.
Next step
Check your understanding
10 quick questions