Async & Concurrency
All I/O operations should be async. Provide sync wrappers for simple use cases.
Core Principles
- Async for I/O — network, file, database operations
- Sync wrapper available — for consumers who don’t need async
- No blocking in async — never call sync I/O in async context
- Proper cleanup — use context managers for resources
Basic Patterns
Async Function
import asyncio
import httpx
async def get_user(user_id: int) -> User | None:
async with httpx.AsyncClient() as client:
response = await client.get(f"/api/users/{user_id}")
if response.status_code == 404:
return None
return User.model_validate(response.json())Async Context Manager
from contextlib import asynccontextmanager
from typing import AsyncIterator
class DatabasePool:
async def startup(self) -> None:
self._pool = await asyncpg.create_pool(self._dsn, min_size=5, max_size=20)
async def shutdown(self) -> None:
if self._pool:
await self._pool.close()
@asynccontextmanager
async def transaction(self) -> AsyncIterator:
async with self._pool.acquire() as conn:
async with conn.transaction():
yield connAsync Iterator
class PaginatedFetcher:
"""Iterate over paginated API results."""
def __init__(self, base_url: str, per_page: int = 100):
self._base_url = base_url
self._per_page = per_page
self._page = 1
self._has_more = True
def __aiter__(self) -> "PaginatedFetcher":
return self
async def __anext__(self) -> list[dict]:
if not self._has_more:
raise StopAsyncIteration
async with httpx.AsyncClient() as client:
response = await client.get(
self._base_url,
params={"page": self._page, "per_page": self._per_page},
)
data = response.json()
if not data["items"]:
raise StopAsyncIteration
self._has_more = data.get("has_more", False)
self._page += 1
return data["items"]
# Usage
async for batch in PaginatedFetcher("https://api.example.com/items"):
process(batch)Concurrent Operations
asyncio.gather — Parallel Execution
async def fetch_user_data(user_id: int) -> dict:
"""Fetch all user data concurrently."""
profile, posts, followers = await asyncio.gather(
fetch_profile(user_id),
fetch_posts(user_id),
fetch_followers(user_id),
)
return {"profile": profile, "posts": posts, "followers": followers}With error handling — return_exceptions=True prevents one failure from canceling others:
async def fetch_many(user_ids: list[int]) -> list[User]:
tasks = [fetch_user(uid) for uid in user_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
users = []
for uid, result in zip(user_ids, results):
if isinstance(result, Exception):
logger.error(f"Failed to fetch user {uid}: {result}")
else:
users.append(result)
return usersSemaphore — Concurrency Limiting
class RateLimitedFetcher:
def __init__(self, max_concurrent: int = 10):
self._semaphore = asyncio.Semaphore(max_concurrent)
async def fetch(self, url: str) -> dict:
async with self._semaphore:
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.json()
async def fetch_many(self, urls: list[str]) -> list[dict]:
tasks = [self.fetch(url) for url in urls]
return await asyncio.gather(*tasks, return_exceptions=True)Timeout
async def fetch_with_timeout(url: str, timeout: float = 10.0) -> dict:
try:
return await asyncio.wait_for(_fetch_impl(url), timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"Request to {url} timed out after {timeout}s")
async def fetch_with_fallback(primary: str, fallback: str) -> dict:
try:
return await asyncio.wait_for(_fetch_impl(primary), timeout=5.0)
except (asyncio.TimeoutError, httpx.HTTPError):
logger.warning("Primary failed, trying fallback")
return await _fetch_impl(fallback)Dual API Pattern
Separate Classes (Preferred)
class UserService:
"""Synchronous user service."""
def __init__(self, config: Config):
self._client = httpx.Client(base_url=config.base_url)
def get_user(self, user_id: int) -> User:
response = self._client.get(f"/users/{user_id}")
response.raise_for_status()
return User.model_validate(response.json())
def close(self) -> None:
self._client.close()
class AsyncUserService:
"""Asynchronous user service."""
def __init__(self, config: Config):
self._config = config
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(base_url=self._config.base_url)
return self._client
async def get_user(self, user_id: int) -> User:
client = await self._get_client()
response = await client.get(f"/users/{user_id}")
response.raise_for_status()
return User.model_validate(response.json())
async def close(self) -> None:
if self._client:
await self._client.aclose()
self._client = NoneSync Wrapper Around Async
def run_sync(coro):
"""Run async function synchronously."""
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
return pool.submit(asyncio.run, coro).result()
class DualModeService:
async def get_user_async(self, user_id: int) -> User:
"""Async implementation."""
...
def get_user(self, user_id: int) -> User:
"""Sync wrapper."""
return run_sync(self.get_user_async(user_id))Streaming
HTTP Streaming
async def stream_large_file(url: str) -> AsyncIterator[bytes]:
async with httpx.AsyncClient() as client:
async with client.stream("GET", url) as response:
async for chunk in response.aiter_bytes(chunk_size=8192):
yield chunkServer-Sent Events
async def stream_events(url: str) -> AsyncIterator[dict]:
async with httpx.AsyncClient() as client:
async with client.stream("GET", url) as response:
async for line in response.aiter_lines():
if line.startswith("data:"):
yield json.loads(line[5:])Background Tasks
class BackgroundTaskManager:
def __init__(self):
self._tasks: set[asyncio.Task] = set()
def start_task(self, coro, name: str | None = None) -> asyncio.Task:
task = asyncio.create_task(coro, name=name)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return task
async def cancel_all(self) -> None:
for task in self._tasks:
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)Periodic Task
async def run_periodic(
func: Callable,
interval: float,
stop_event: asyncio.Event,
) -> None:
while not stop_event.is_set():
try:
await func()
except Exception as e:
logger.error(f"Periodic task failed: {e}")
try:
await asyncio.wait_for(stop_event.wait(), timeout=interval)
except asyncio.TimeoutError:
pass # Expected — continue loopGraceful Shutdown
class AsyncService:
def __init__(self):
self._shutdown_event = asyncio.Event()
self._running = False
async def start(self) -> None:
self._running = True
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(
sig, lambda: asyncio.create_task(self.stop())
)
await self._run()
async def stop(self) -> None:
self._running = False
self._shutdown_event.set()
async def _run(self) -> None:
while self._running:
try:
await self._process()
except Exception as e:
logger.error(f"Error in main loop: {e}")
try:
await asyncio.wait_for(self._shutdown_event.wait(), timeout=1.0)
break
except asyncio.TimeoutError:
continueRules
- Async for all I/O — network, file, database operations
- Provide sync wrapper — for consumers who don’t need async
- No blocking in async — never call sync I/O from async context
- Use context managers —
async withfor resources gatherfor parallel I/O — withreturn_exceptions=True- Semaphore for rate limiting — don’t flood external services
- Always set timeouts —
asyncio.wait_for()or client-level timeout - Track background tasks — store references, cancel on shutdown
- Graceful shutdown — handle SIGINT/SIGTERM, drain tasks
Last updated on