Async Patterns & Best Practices
Master async context managers, iterators, comprehensive error handling, and production patterns
Beyond the Basics
Once you understand async fundamentals, the next step is mastering advanced patterns that make your async code robust, maintainable, and production-ready. This lesson covers async context managers for resource management, async iterators for streaming data, comprehensive error handling strategies, and battle-tested patterns used in real-world systems.
What You'll Master:
- Async Context Managers: Properly manage resources with async with
- Async Iterators & Generators: Stream data efficiently
- Error Handling: Catch and recover from failures gracefully
- Production Patterns: Connection pools, circuit breakers, retries
- Testing Async Code: Strategies for reliable async tests
Async Context Managers
Async context managers ensure proper setup and cleanup of async resources like database connections, file handles, and network connections.
Using async with
import aiohttp
import asyncio
# Async context manager ensures proper cleanup
async def fetch_data(url):
# aiohttp session is an async context manager
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
return data
# Session automatically closed here, even if error occurs
async def main():
result = await fetch_data('https://api.example.com/data')
print(result)
asyncio.run(main())
# Why async with?
# - Ensures cleanup happens (like finally block)
# - Works with await in __aenter__ and __aexit__
# - Prevents resource leaksCreating Custom Async Context Managers
import asyncio
from contextlib import asynccontextmanager
# Method 1: Using __aenter__ and __aexit__
class AsyncDatabaseConnection:
def __init__(self, host):
self.host = host
self.connection = None
async def __aenter__(self):
"""Called when entering 'async with' block"""
print(f"Connecting to {self.host}...")
await asyncio.sleep(1) # Simulate connection
self.connection = f"Connected to {self.host}"
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Called when exiting 'async with' block"""
print(f"Closing connection to {self.host}...")
await asyncio.sleep(0.5) # Simulate cleanup
self.connection = None
return False # Don't suppress exceptions
# Usage
async def main():
async with AsyncDatabaseConnection("localhost") as db:
print(f"Using: {db.connection}")
# Do work with connection
# Connection automatically closed
# Method 2: Using @asynccontextmanager decorator
@asynccontextmanager
async def database_connection(host):
"""Simpler way to create async context manager"""
# Setup
print(f"Connecting to {host}...")
connection = await connect_to_database(host)
try:
yield connection # Provide resource
finally:
# Cleanup (always runs)
print(f"Closing connection to {host}...")
await connection.close()
# Usage
async def query_database():
async with database_connection("localhost") as conn:
result = await conn.query("SELECT * FROM users")
return result
asyncio.run(main())Real-World Example: Connection Pool
import asyncio
from contextlib import asynccontextmanager
class AsyncConnectionPool:
def __init__(self, max_connections=10):
self.max_connections = max_connections
self.available = []
self.in_use = set()
self.semaphore = asyncio.Semaphore(max_connections)
async def create_connection(self):
"""Create a new connection"""
await asyncio.sleep(0.1) # Simulate connection time
return f"Connection-{id(object())}"
@asynccontextmanager
async def acquire(self):
"""Acquire a connection from the pool"""
async with self.semaphore:
# Get existing or create new
if self.available:
conn = self.available.pop()
else:
conn = await self.create_connection()
self.in_use.add(conn)
try:
yield conn
finally:
# Return to pool
self.in_use.remove(conn)
self.available.append(conn)
# Usage
async def worker(pool, worker_id):
async with pool.acquire() as conn:
print(f"Worker {worker_id} using {conn}")
await asyncio.sleep(1) # Simulate work
print(f"Worker {worker_id} done")
async def main():
pool = AsyncConnectionPool(max_connections=3)
# 10 workers sharing 3 connections
await asyncio.gather(*[
worker(pool, i) for i in range(10)
])
asyncio.run(main())- Guarantees cleanup even if exceptions occur
- Makes resource management explicit and safe
- Prevents resource leaks in long-running applications
- Works seamlessly with async operations
Async Iterators & Generators
Async iterators allow you to iterate over data that's fetched asynchronously, perfect for streaming large datasets, paginated APIs, or real-time data feeds.
async for: Consuming Async Iterators
import asyncio
# Async generator function
async def fetch_pages(num_pages):
"""Simulate fetching pages one at a time"""
for page in range(1, num_pages + 1):
print(f"Fetching page {page}...")
await asyncio.sleep(1) # Simulate API call
yield {"page": page, "data": f"Content of page {page}"}
# Consuming with async for
async def main():
async for page in fetch_pages(5):
print(f"Processing: {page}")
# Process each page as it arrives
# Memory efficient - one page at a time
asyncio.run(main())
# Output:
# Fetching page 1...
# Processing: {'page': 1, 'data': 'Content of page 1'}
# Fetching page 2...
# Processing: {'page': 2, 'data': 'Content of page 2'}
# ...Creating Async Iterators
import asyncio
import aiohttp
# Method 1: Using __aiter__ and __anext__
class AsyncAPIIterator:
def __init__(self, base_url, max_pages):
self.base_url = base_url
self.max_pages = max_pages
self.current_page = 1
def __aiter__(self):
"""Return the iterator object (self)"""
return self
async def __anext__(self):
"""Return the next item or raise StopAsyncIteration"""
if self.current_page > self.max_pages:
raise StopAsyncIteration
# Fetch page asynchronously
url = f"{self.base_url}?page={self.current_page}"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
self.current_page += 1
return data
# Usage
async def process_api_data():
async for page_data in AsyncAPIIterator("https://api.example.com/data", 5):
print(f"Processing page: {page_data}")
# Method 2: Async Generator (simpler)
async def paginated_api(base_url, max_pages):
"""Async generator - cleaner syntax"""
for page in range(1, max_pages + 1):
async with aiohttp.ClientSession() as session:
async with session.get(f"{base_url}?page={page}") as response:
data = await response.json()
yield data
# Usage
async def main():
async for data in paginated_api("https://api.example.com/data", 5):
print(data)
asyncio.run(main())Real-World: Streaming Database Results
import asyncio
import asyncpg
async def stream_large_table(pool, batch_size=1000):
"""Stream millions of rows without loading all into memory"""
async with pool.acquire() as conn:
# Use cursor for efficient streaming
async with conn.transaction():
cursor = await conn.cursor(
"SELECT * FROM large_table ORDER BY id"
)
while True:
# Fetch batch
rows = await cursor.fetch(batch_size)
if not rows:
break
# Yield batch for processing
for row in rows:
yield row
# Usage
async def process_records():
pool = await asyncpg.create_pool(
'postgresql://user:pass@localhost/db'
)
processed = 0
async for record in stream_large_table(pool):
# Process one record at a time
await process_record(record)
processed += 1
if processed % 10000 == 0:
print(f"Processed {processed} records...")
await pool.close()
print(f"Total processed: {processed}")
asyncio.run(process_records())
# Benefits:
# - Low memory usage (only batch_size in memory)
# - Can process billions of rows
# - Start processing immediately (no wait for full load)Comprehensive Error Handling
Async code needs robust error handling. Unhandled exceptions in tasks can cause silent failures, and errors in concurrent tasks need special attention.
Try/Except in Async Functions
import asyncio
import aiohttp
async def fetch_with_error_handling(url):
"""Proper error handling in async function"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=10) as response:
response.raise_for_status() # Raise for 4xx/5xx
return await response.json()
except asyncio.TimeoutError:
print(f"Timeout fetching {url}")
return None
except aiohttp.ClientError as e:
print(f"HTTP error for {url}: {e}")
return None
except Exception as e:
print(f"Unexpected error for {url}: {e}")
return None
finally:
# Cleanup code (always runs)
print(f"Finished attempt for {url}")
# Usage
async def main():
result = await fetch_with_error_handling("https://api.example.com/data")
if result:
print(f"Success: {result}")
else:
print("Failed to fetch data")
asyncio.run(main())Handling Errors in Concurrent Tasks
import asyncio
async def risky_task(task_id):
"""Task that might fail"""
await asyncio.sleep(1)
if task_id == 2:
raise ValueError(f"Task {task_id} failed!")
return f"Task {task_id} succeeded"
# Method 1: gather() with return_exceptions
async def main_gather():
results = await asyncio.gather(
risky_task(1),
risky_task(2), # This will fail
risky_task(3),
return_exceptions=True # Don't crash, return exceptions
)
for i, result in enumerate(results, 1):
if isinstance(result, Exception):
print(f"Task {i} failed: {result}")
else:
print(f"Task {i}: {result}")
# Method 2: Wrapping tasks individually
async def safe_task(task_id):
"""Wrapper with error handling"""
try:
return await risky_task(task_id)
except Exception as e:
print(f"Caught error in task {task_id}: {e}")
return None # Return default value
async def main_wrapped():
results = await asyncio.gather(
safe_task(1),
safe_task(2), # Error caught internally
safe_task(3)
)
print(f"All results: {results}")
# Method 3: Using create_task with error handling
async def main_tasks():
tasks = [
asyncio.create_task(safe_task(i))
for i in range(1, 4)
]
results = await asyncio.gather(*tasks)
print(f"Results: {results}")
asyncio.run(main_gather())Retry Pattern with Exponential Backoff
import asyncio
import random
async def retry_with_backoff(
coro_func,
max_retries=3,
base_delay=1,
max_delay=60,
exponential_base=2
):
"""Retry with exponential backoff"""
for attempt in range(max_retries):
try:
return await coro_func()
except Exception as e:
if attempt == max_retries - 1:
# Last attempt, re-raise
raise
# Calculate delay with exponential backoff
delay = min(
base_delay * (exponential_base ** attempt),
max_delay
)
# Add jitter to prevent thundering herd
jitter = random.uniform(0, delay * 0.1)
total_delay = delay + jitter
print(f"Attempt {attempt + 1} failed: {e}")
print(f"Retrying in {total_delay:.2f}s...")
await asyncio.sleep(total_delay)
# Usage
async def unreliable_api_call():
"""Simulates unreliable API"""
if random.random() < 0.7:
raise Exception("API temporarily unavailable")
return {"data": "success"}
async def main():
try:
result = await retry_with_backoff(
unreliable_api_call,
max_retries=5,
base_delay=1
)
print(f"Success: {result}")
except Exception as e:
print(f"Failed after all retries: {e}")
asyncio.run(main())Production-Ready Patterns
Timeout Context Manager
Prevent operations from hanging indefinitely.
import asyncio
async def operation_with_timeout():
try:
# Timeout after 5 seconds
async with asyncio.timeout(5):
result = await slow_operation()
return result
except asyncio.TimeoutError:
print("Operation timed out!")
return None
# Alternative: wait_for
async def alternative_timeout():
try:
result = await asyncio.wait_for(
slow_operation(),
timeout=5.0
)
return result
except asyncio.TimeoutError:
print("Operation timed out!")
return None
# Nested timeouts
async def nested_timeouts():
try:
# Outer timeout: 10 seconds
async with asyncio.timeout(10):
# Inner timeout: 5 seconds
async with asyncio.timeout(5):
result = await slow_operation()
# This has 5 seconds remaining
await another_operation()
except asyncio.TimeoutError:
print("Operation exceeded timeout")
asyncio.run(operation_with_timeout())Testing Async Code
Testing async code requires special considerations and tools.
import asyncio
import pytest
# Using pytest-asyncio
@pytest.mark.asyncio
async def test_async_function():
"""Test async function with pytest"""
result = await fetch_data("https://api.example.com")
assert result["status"] == "success"
# Testing with mocks
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_with_mock():
"""Test with mocked async calls"""
mock_response = AsyncMock()
mock_response.json.return_value = {"data": "test"}
with patch('aiohttp.ClientSession.get', return_value=mock_response):
result = await fetch_data("https://api.example.com")
assert result["data"] == "test"
# Testing timeouts
@pytest.mark.asyncio
async def test_timeout():
"""Test that operation times out"""
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(slow_operation(), timeout=1.0)
# Testing concurrent operations
@pytest.mark.asyncio
async def test_concurrent_execution():
"""Test multiple concurrent operations"""
start = asyncio.get_event_loop().time()
results = await asyncio.gather(
task(1), task(2), task(3)
)
elapsed = asyncio.get_event_loop().time() - start
assert len(results) == 3
assert elapsed < 2.0 # Should run concurrently
# Fixture for async setup/teardown
@pytest.fixture
async def db_connection():
"""Async fixture"""
conn = await create_connection()
yield conn
await conn.close()
@pytest.mark.asyncio
async def test_with_fixture(db_connection):
"""Use async fixture"""
result = await db_connection.query("SELECT 1")
assert result is not NoneAsync Best Practices
✓ Do This
- Use async context managers for resources
- Always handle exceptions in tasks
- Use return_exceptions=True in gather()
- Implement timeouts for all I/O operations
- Use async iterators for streaming data
- Implement retry logic with backoff
- Use circuit breakers for external services
- Limit concurrency with semaphores
- Log errors with context
- Test async code thoroughly
✗ Avoid This
- Letting exceptions crash tasks silently
- No timeouts on I/O operations
- Unbounded concurrency
- Mixing blocking and async code
- Not using async context managers
- Ignoring backpressure
- No retry logic for transient failures
- Creating too many tasks at once
- Not testing edge cases
- Swallowing errors without logging
Key Takeaways
- Async context managers ensure cleanup - use async with for resources
- Async iterators enable streaming - process data as it arrives
- Error handling is critical - use return_exceptions and try/except
- Implement retry logic - exponential backoff for resilience
- Use circuit breakers - prevent cascading failures
- Timeouts prevent hangs - always set reasonable timeouts
- Worker pools limit concurrency - prevent resource exhaustion
- Test async code properly - use pytest-asyncio and mocks
Practice Exercises
Exercise 1: Async Database Pool
Build an async connection pool for database connections with proper context manager support. Implement connection timeout, max pool size, and connection recycling. Test with concurrent queries.
Exercise 2: Resilient API Client
Create an async API client with circuit breaker, retry logic with exponential backoff, rate limiting using semaphore, and comprehensive error handling. Should gracefully handle timeouts, network errors, and API rate limits.
Exercise 3: Streaming Data Processor
Build an async data processor that streams records from a paginated API using async iterators, processes them in batches using a worker pool, and handles errors without stopping the entire pipeline. Include metrics (records processed, errors encountered).
Additional Resources
- asyncio docs: docs.python.org/3/library/asyncio.html
- aiohttp: docs.aiohttp.org - async HTTP client/server
- pytest-asyncio: Testing async code
- tenacity: Retry library for Python
- Design Patterns: "Designing Data-Intensive Applications" by Martin Kleppmann
- Circuit Breakers: martinfowler.com/bliki/CircuitBreaker.html
What's Next?
You've mastered async patterns! Now let's explore threading and concurrency to handle parallel execution with threads.
- Threading Basics - Create and manage threads for concurrent execution
- Thread Synchronization - Locks, semaphores, and thread-safe code
- GIL Understanding - Learn about Python's Global Interpreter Lock