Resources (file handles, sockets, locks, connections, transactions, tasks) outlive the call that opens them unless something explicitly closes them. The explicit thing must be in your code.
import asyncio
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
import asyncpg
import httpx
POOL_MAX = 50 # downstream capacity, not a guess
HTTP_TIMEOUT = 5.0 # seconds, user-perceived SLA
@asynccontextmanager
async def fetcher() -> AsyncIterator[tuple[asyncpg.Pool, httpx.AsyncClient]]:
pool = await asyncpg.create_pool(min_size=10, max_size=POOL_MAX)
limits = httpx.Limits(max_connections=POOL_MAX, max_keepalive_connections=10)
try:
async with httpx.AsyncClient(limits=limits) as client:
yield pool, client
finally:
await pool.close() # deterministic, even on error
async def sync_orders(urls: list[str]) -> list[bytes]:
async with fetcher() as (pool, client):
async with asyncio.timeout(HTTP_TIMEOUT):
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(client.get(url)) for url in urls]
async with pool.acquire() as conn:
await conn.executemany("INSERT INTO seen(url) VALUES($1)", [(u,) for u in urls])
return [t.result().content for t in tasks]Every paired resource enters through async with (13.1, 13.3); fetcher is an @asynccontextmanager whose try/finally closes the pool deterministically (13.2); the pool and HTTP client are bounded by POOL_MAX and asyncio.timeout guards the I/O (13.5, 13.7, 13.10); TaskGroup owns the fan-out task lifecycles (13.4). No __del__, no manual close in the happy path (13.9).
Reasoning, step by step:
with open(path) as f: ...closes the file on exit — normal or exceptional. Safer than manualtry/finally.- Manual
try: f = open(path); ... finally: f.close()is the same thing, verbose, and forgettable. - Use for: files, locks, transactions, subprocess handles, temporary state, sockets.
- Anti-pattern: opening a resource and returning the open object from a function. Caller doesn't know to
withit. Take a callback or be a context manager yourself.
Enforcement: flake8-bugbear B017/pylint consider-using-with; review for functions returning open handles.
Reasoning, step by step:
@contextmanagerturns a generator into a context manager — clearer than a class with__enter__/__exit__for simple cases.- Pattern:
from contextlib import contextmanager from collections.abc import Iterator from pathlib import Path import tempfile, shutil @contextmanager def temporary_workdir() -> Iterator[Path]: d = Path(tempfile.mkdtemp()) try: yield d finally: shutil.rmtree(d)
- Always
try/finallyaround theyield. Thefinallyruns even when the body raises. - For async resources:
@asynccontextmanager+async def+async with.
Enforcement: review; every @contextmanager generator wraps its yield in try/finally.
Reasoning, step by step:
async with httpx.AsyncClient() as client: ...is the async equivalent ofwith. The__aenter__and__aexit__are coroutines.- Use for: async HTTP clients, async database pools, async file handles, async locks (
asyncio.Lock). - Don't mix
withandasync with— if the resource has both, use the async form inside async code, the sync form inside sync code.
Enforcement: ruff ASYNC flags blocking calls in async code; review for sync with on async-capable resources.
Reasoning, step by step:
- Restated from chapter 09:
TaskGroupwaits for all tasks on exit, cancels siblings on error. - Use as the only way to launch tasks you don't intend to outlive the current scope.
- For tasks that should outlive: keep a strong reference, attach a
done_callbackfor cleanup, document the lifecycle.
Enforcement: ruff RUF006 catches dangling create_task; review that ad-hoc tasks launch through a TaskGroup.
Reasoning, step by step:
- Every async I/O without a timeout is a resource leak waiting to happen.
async with asyncio.timeout(5.0): await http.get(url)— the timeout is a hard contract.- Choose by user-perceived SLA. Don't pick "1 hour" because "it should be enough."
Enforcement: review; every external await sits inside an asyncio.timeout or carries a client-level deadline.
Reasoning, step by step:
random.random()is statistical-quality, not cryptographic. Don't use for tokens, nonces, secrets.secrets.token_bytes(32),secrets.token_urlsafe(32),secrets.compare_digest(a, b)for security-sensitive operations.os.urandom(n)is the same source.secretsis the wrapper.- Constant-time comparison for tokens/MACs:
secrets.compare_digest(expected, actual)not==. (Restated from security.md.)
Enforcement: bandit B311 flags random in security contexts; review token comparisons for compare_digest.
Reasoning, step by step:
- Pool size is a system parameter — picked from downstream capacity, expected concurrency, and RAM.
- httpx:
httpx.Limits(max_connections=N, max_keepalive_connections=M). Document the values. - asyncpg:
asyncpg.create_pool(min_size=10, max_size=50). Same. - Monitor: pool exhaustion (waiters), checkout latency. Alert on saturated pool.
Enforcement: review; every pool constructor passes explicit max_size/max_connections from a named constant.
Reasoning, step by step:
- SIGTERM means "you have N seconds; finish what's in flight, then stop."
- Shutdown sequence: stop accepting new work → wait for in-flight to drain (bounded) → close pools → exit.
- A hung shutdown is worse than a forced one — the orchestrator will SIGKILL eventually.
- Pattern in asyncio:
stop = asyncio.Event() def _handle_signal() -> None: stop.set() loop = asyncio.get_running_loop() for sig in (signal.SIGTERM, signal.SIGINT): loop.add_signal_handler(sig, _handle_signal) try: async with asyncio.TaskGroup() as tg: tg.create_task(serve()) tg.create_task(stop.wait()) finally: await asyncio.wait_for(close_resources(), timeout=30.0)
Enforcement: integration test that a SIGTERM drains in-flight work and closes pools within the bounded window.
Reasoning, step by step:
del xunbinds the name. Whether the object is freed depends on reference counts and the GC.__del__(the destructor dunder) is unreliable: order of finalization is undefined, exceptions in__del__are silently logged, cyclic references prevent finalization entirely.- Never rely on
__del__for resource cleanup. Implement__enter__/__exit__(or__aenter__/__aexit__) instead. weakref.finalizeis acceptable for non-critical cleanup that might fire. Not for guaranteed cleanup.
Enforcement: review; no __del__ used for resource cleanup, no del standing in for a context manager.
Reasoning, step by step:
- Restated from root rule §9: every loop, queue, retry, timeout, task list, cache must have a fixed upper bound.
- Common bounds in Python:
itertools.islice(iter, n)for capping an iterator.asyncio.Queue(maxsize=N)andasyncio.Semaphore(N)for capping concurrency.functools.lru_cache(maxsize=N)for memoization (nevermaxsize=Nonein production unless inputs are provably finite).asyncio.timeout(seconds)for every external call.- Pool
max_sizefor every connection pool.
- State the bound at the call site. Magic constants drift; named constants document.
Enforcement: review; lru_cache(maxsize=None), unbounded queues, and uncapped iterators flagged in code review.
Reasoning, step by step:
tempfile.NamedTemporaryFile(),tempfile.TemporaryDirectory()— context-managed, OS-level cleanup.delete=True(default) forNamedTemporaryFile— file deleted on close.- On Windows:
NamedTemporaryFile(delete=False)is sometimes needed because of file-locking rules. Thenos.unlink(path)in afinally.
Enforcement: review; ephemeral files go through tempfile, and delete=False is paired with a finally unlink.
Reasoning, step by step:
subprocess.run(args, timeout=30, check=True, capture_output=True, text=True)is the safe default.shell=False(the default). Nevershell=Truewith user-provided input — command injection (see security.md).- Pass arguments as a list:
["git", "status"], not"git status". - Async equivalent:
asyncio.create_subprocess_execwith explicit timeout viaasyncio.timeout.
Enforcement: bandit B602/B603 flags shell=True and untrusted args; review for timeout= on every subprocess.run.
- Async patterns: chapter 09.
- Security and credentials: security.md.
- Bounded caches: chapter 15.