Cheat sheet — Async & Structured Concurrency¶
Companion to Module 04 — Async & Structured Concurrency · CC BY 4.0 — print it, pin it, share it.
Last reviewed: 2026-07
asyncio basics¶
import asyncio
async def fetch(n): # a coroutine — calling it returns an awaitable
await asyncio.sleep(0.3) # yields the loop while "waiting" (I/O stand-in)
return n * 2
async def main():
result = await fetch(21) # await runs it and gets the value
return result
asyncio.run(main()) # ONE entry point — starts the loop, runs, closes it
# Run many coroutines concurrently, collect results in order
results = await asyncio.gather(fetch(1), fetch(2), fetch(3))
# Don't let one failure cancel the rest — get exceptions back as values
results = await asyncio.gather(*tasks, return_exceptions=True)
# Structured concurrency (Python 3.11+): a task group cancels siblings on error
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch(1))
t2 = tg.create_task(fetch(2))
# all tasks are awaited on exit; failures raise as an ExceptionGroup
# Process results as each finishes, not in submit order
for coro in asyncio.as_completed(tasks):
result = await coro
httpx.AsyncClient — the async HTTP client¶
import httpx
# ONE client for the whole batch — reuses the connection pool. Not requests (sync-only).
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get("https://api.example/lookup", params={"ip": ip})
r = await client.post("https://api.example/enrich", json={"iocs": batch})
r.raise_for_status() # raise on 4xx/5xx
data = r.json()
code = r.status_code
wait = r.headers.get("Retry-After")
# Bound the transport pool (complements your semaphore) + explicit timeouts
limits = httpx.Limits(max_connections=20, max_keepalive_connections=10)
timeout = httpx.Timeout(10.0, connect=5.0) # total, with a tighter connect budget
async with httpx.AsyncClient(limits=limits, timeout=timeout) as client:
...
Bounded concurrency — the semaphore¶
# K = the API's published rate limit, NOT how fast your CPU is
sem = asyncio.Semaphore(8)
async def enrich(client, ioc):
async with sem: # at most K coroutines past this line at once
r = await client.get(url, params={"q": ioc})
return r.json()
async with httpx.AsyncClient() as client:
tasks = [enrich(client, i) for i in indicators]
results = await asyncio.gather(*tasks, return_exceptions=True)
Retry / backoff honoring 429¶
async def enrich(client, ioc, max_retries=5):
for attempt in range(max_retries):
r = await client.get(url, params={"q": ioc})
if r.status_code != 429:
r.raise_for_status()
return Ok(r.json())
# 429 — the server told you to slow down. Obey it.
retry_after = r.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
delay += random.uniform(0, 0.5) # jitter — avoid lockstep retries
await asyncio.sleep(delay) # asyncio.sleep, NOT time.sleep
return Err("rate-limited past retry budget")
# Or express it declaratively with tenacity
from tenacity import retry, wait_exponential_jitter, stop_after_attempt
@retry(wait=wait_exponential_jitter(initial=1, max=30), stop=stop_after_attempt(5))
async def enrich(client, ioc): ...
Handling partial failure¶
from dataclasses import dataclass
@dataclass
class Ok: data: dict
@dataclass
class Err: reason: str
# Every task returns a result object — the batch always completes
results = await asyncio.gather(*[enrich(client, i) for i in iocs],
return_exceptions=True)
ok = [r for r in results if isinstance(r, Ok)]
bad = [r for r in results if not isinstance(r, Ok)] # triage separately
Gotchas worth remembering¶
- Unbounded
gatheris a thundering herd.await asyncio.gather(*[enrich(i) for i in iocs])opens N connections at once, floods the API, and gets you429'd or key-banned. Always bound —asyncio.Semaphore(K)and/orhttpx.Limits. - Bare
gathercancels the batch on the first raise. One failed call sinks the other 9,999 results. Usereturn_exceptions=True(minimum) or a typed per-item result (professional). - Never
time.sleep()inside a coroutine — it blocks the whole event loop, freezing every other task. Useawait asyncio.sleep(...). Same for any sync/blocking call (requests, heavy CPU). - A
429is an instruction, not an error. HonorRetry-After(seconds or an HTTP date) before falling back to exponential backoff; retrying instantly is the herd behavior that gets you banned. - Always set timeouts.
httpx.AsyncClient()has a default, but be explicit — one hung request with no timeout can stall your bound forever. - Reuse one client. A fresh
AsyncClientper call throws away the connection pool — create it once withasync witharound the whole batch. - Async is for waiting, not computing. It overlaps I/O in one thread; CPU-bound work still serializes. Reach for it when the bottleneck is the network.
Only send requests to systems you own or have explicit written permission to test.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).