Skip to content
v0.1.0latest release21 skills2 categories3 sub-categories22 tested scripts68 reference docsContact us

Async Context Managers

Reference for python-context-manager-writer · 389 lines

Programming / Pythonv1.0.0Updated OCT 20 20266 references1 script

async with, __aenter__/__aexit__, cancellation, client lifecycles, semaphores, task groups and async generators.

Contents

Protocol

python
async with EXPR as TARGET:
    BODY

is equivalent to:

python
manager = EXPR
aexit = type(manager).__aexit__
value = await type(manager).__aenter__(manager)
try:
    TARGET = value
    BODY
except BaseException as exc:
    if not await aexit(manager, type(exc), exc, exc.__traceback__):
        raise
else:
    await aexit(manager, None, None, None)
RuleDetail
__aenter__/__aexit__ must be async def (or return awaitables)A plain def __aexit__ returning False makes await False fail with TypeError.
async with only works inside async def(or an async REPL / Jupyter top level)
Truthy return from __aexit__ suppressesSame as sync
async with on a sync-only manager failsTypeError: 'X' object does not support the asynchronous context manager protocol (3.11+ wording).
with on an async-only manager failsTypeError (httpx.AsyncClient, aiohttp.ClientSession, asyncio.Lock (3.9+ removed with await lock))

Class-based async managers

python
from types import TracebackType
from typing import Optional, Type

import asyncpg


class VectorTxn:
    """asyncpg transaction around pgvector writes."""

    def __init__(self, pool: asyncpg.Pool) -> None:
        self._pool = pool

    async def __aenter__(self) -> asyncpg.Connection:
        self._conn = await self._pool.acquire()
        try:
            self._tx = self._conn.transaction()
            await self._tx.start()
        except BaseException:
            await self._pool.release(self._conn)   # __aexit__ will not run
            raise
        return self._conn

    async def __aexit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc: Optional[BaseException],
        tb: Optional[TracebackType],
    ) -> None:
        try:
            if exc_type is None:
                await self._tx.commit()
            else:
                await self._tx.rollback()
        finally:
            await self._pool.release(self._conn)


async def upsert_chunks(pool, chunks) -> None:
    async with VectorTxn(pool) as conn:
        await conn.executemany(
            "INSERT INTO chunks (id, text, embedding) VALUES ($1, $2, $3) "
            "ON CONFLICT (id) DO UPDATE SET text = EXCLUDED.text, embedding = EXCLUDED.embedding",
            [(c.id, c.text, c.embedding) for c in chunks],
        )

(asyncpg already provides async with pool.acquire() as conn, conn.transaction():; the class shows the protocol.)

@asynccontextmanager

python
from contextlib import asynccontextmanager
from typing import AsyncIterator


@asynccontextmanager
async def leased_connection(pool) -> AsyncIterator["asyncpg.Connection"]:
    conn = await pool.acquire()
    try:
        yield conn
    finally:
        await pool.release(conn)

Typing: AsyncIterator[T] (or AsyncGenerator[T, None]). A plain def with @asynccontextmanager, or an async def with @contextmanager, is a bug (ctx_tool.py CTX009).

Cancellation and cleanup

asyncio.CancelledError is a BaseException (3.8+). When a task is cancelled while inside async with, the error is raised at the current await in the body, then __aexit__ runs with it.

ConcernGuidance
Never swallow CancelledErrorexcept Exception does not catch it (good); except BaseException / bare except must re-raise.
Cleanup that awaits can be cancelled againIf cleanup must finish (release a lease, write a checkpoint), use asyncio.shield and keep it short.
asyncio.timeout() / wait_for cancel the inner taskCleanup inside the body's managers still runs.
Uncancel semantics (3.11)Code that catches CancelledError to do cleanup then continues should call task.uncancel(); most code should simply re-raise.
python
import asyncio


class Lease:
    def __init__(self, broker, job_id: str) -> None:
        self.broker, self.job_id = broker, job_id

    async def __aenter__(self) -> "Lease":
        await self.broker.claim(self.job_id)
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        # The job must be released even if we are being cancelled; shield the await
        # so a second cancellation does not interrupt the release request itself.
        await asyncio.shield(self.broker.release(self.job_id))

Note: shield protects the inner coroutine, but the outer await still raises CancelledError if cancelled again; the release keeps running in the background.

Concurrency limits: Semaphore and Lock

python
import asyncio

EMBED_LIMIT = asyncio.Semaphore(16)        # create ONCE, share across tasks
                                           # (3.10+: no loop binding at construction)


async def embed_one(client, text: str) -> list[float]:
    async with EMBED_LIMIT:                # acquire; release guaranteed
        resp = await client.embeddings.create(model="text-embedding-3-small", input=text)
        return resp.data[0].embedding


async def embed_all(client, texts: list[str]) -> list[list[float]]:
    return await asyncio.gather(*(embed_one(client, t) for t in texts))

Rules:

  • async with asyncio.Semaphore(16): inside the function creates a new semaphore per call — no limit at all (CTX011).
  • Never await sem.acquire() without try/finally: sem.release() (CTX002).
  • BoundedSemaphore raises if released more than acquired — catches double release bugs.
  • Python 3.9: asyncio primitives created at import time bind to the loop that first uses them in some versions; on 3.9 prefer creating them inside async def main() and passing them down. 3.10+ removed the loop binding.

Rate limiting (requests per second) is different from concurrency limiting; see the semaphore recipe printed by ctx_tool.py scaffold --recipe semaphore which combines both.

Cancelling background tasks on exit

A common agent pattern: start a heartbeat / token-refresh / progress task for the lifetime of a block, and make sure it dies with the block.

python
import asyncio
import contextlib
from contextlib import asynccontextmanager
from typing import AsyncIterator, Awaitable, Callable


@asynccontextmanager
async def background(fn: Callable[[], Awaitable[None]], interval: float) -> AsyncIterator[asyncio.Task]:
    async def loop() -> None:
        while True:
            await fn()
            await asyncio.sleep(interval)

    task = asyncio.create_task(loop())
    try:
        yield task
    finally:
        task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await task                    # wait for it to actually stop


async def run_long_agent(session) -> None:
    async with background(session.heartbeat, interval=10):
        await session.run_steps()

If the task may have failed with a real error before cancellation, inspect it: if task.done() and not task.cancelled() and task.exception(): log....

TaskGroup and timeout (3.11)

asyncio.TaskGroup is an async context manager that waits for all child tasks and cancels the rest when one fails, raising an ExceptionGroup.

python
import asyncio


async def answer_with_tools(question: str) -> dict:
    async with asyncio.timeout(30):                 # 3.11+: cancels the block on expiry
        async with asyncio.TaskGroup() as tg:       # 3.11+
            web = tg.create_task(search_web(question))
            docs = tg.create_task(search_vector_store(question))
            sql = tg.create_task(query_warehouse(question))
    return {"web": web.result(), "docs": docs.result(), "sql": sql.result()}


try:
    result = asyncio.run(answer_with_tools("Q3 revenue by region?"))
except* TimeoutError:                                # 3.11+ except*
    ...

Notes: asyncio.timeout raises TimeoutError (builtin) out of the block. Pre-3.11 use asyncio.wait_for(coro, 30) and asyncio.gather(..., return_exceptions=False) plus manual cancellation. Exceptions from a TaskGroup arrive wrapped in an ExceptionGroup, so handle them with except*.

HTTP client lifecycle: httpx and aiohttp

Creating a client per request throws away connection pooling (TLS handshake per call, often +50–200 ms to an LLM API) and leaks sockets if not closed.

PatternWhen
async with httpx.AsyncClient() as client: around a batchScripts, jobs, notebooks
One client created in FastAPI lifespan, closed after yieldServices
Client owned by a wrapper class with aclose() + __aenter__/__aexit__SDK-style wrappers
python
import httpx


class ChatClient:
    """Thin LLM API wrapper that owns its HTTP pool."""

    def __init__(self, base_url: str, api_key: str) -> None:
        self._http = httpx.AsyncClient(
            base_url=base_url,
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=httpx.Timeout(60.0, connect=5.0),
            limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
        )

    async def complete(self, messages: list[dict], model: str) -> str:
        r = await self._http.post("/v1/chat/completions", json={"model": model, "messages": messages})
        r.raise_for_status()
        return r.json()["choices"][0]["message"]["content"]

    async def aclose(self) -> None:
        await self._http.aclose()

    async def __aenter__(self) -> "ChatClient":
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        await self.aclose()

aiohttp: ClientSession must be created inside a running loop and closed with await session.close(); responses must be released (async with session.get(...) as r:).

python
import aiohttp


async def fetch_all(urls: list[str]) -> list[str]:
    timeout = aiohttp.ClientTimeout(total=30)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        async def one(url: str) -> str:
            async with session.get(url) as r:      # releases the connection back to the pool
                r.raise_for_status()
                return await r.text()
        return await asyncio.gather(*(one(u) for u in urls))

Streaming responses (httpx client.stream(...)) are themselves context managers; leaving them un-exited leaks the connection:

python
async with client.stream("POST", "/v1/chat/completions", json=payload) as r:
    async for line in r.aiter_lines():
        handle(line)

Async generators need aclosing

An async generator abandoned mid-iteration is not finalized immediately; its finally runs later (via the loop's asyncgen hooks) or never, if the loop closes first. Wrap generators that hold resources:

python
from contextlib import aclosing   # 3.10+


async def read_events(stream):
    async with aclosing(sse_events(stream)) as events:
        async for ev in events:
            if ev.type == "done":
                break

Dual sync/async managers

Some resources are used from both worlds (a tracing span, a timer). Implement both protocols on one class; keep shared logic in plain methods.

python
import time


class Span:
    def __init__(self, name: str) -> None:
        self.name = name

    def _start(self) -> "Span":
        self.t0 = time.perf_counter()
        return self

    def _end(self, exc: BaseException | None) -> None:   # 3.10+ union syntax; use Optional on 3.9
        status = "error" if exc else "ok"
        export(self.name, time.perf_counter() - self.t0, status)

    def __enter__(self) -> "Span":
        return self._start()

    def __exit__(self, exc_type, exc, tb) -> None:
        self._end(exc)

    async def __aenter__(self) -> "Span":
        return self._start()

    async def __aexit__(self, exc_type, exc, tb) -> None:
        self._end(exc)

Never call blocking cleanup (e.g. requests or a sync DB driver) inside __aexit__; it stalls the event loop. Offload with await asyncio.to_thread(fn) (3.9+).

Common async mistakes

MistakeSymptomFix
with httpx.AsyncClient()TypeError / AttributeError: __enter__async with
Forgetting await on client.aclose()"coroutine was never awaited" warning, socket leakawait client.aclose()
New AsyncClient per requestLatency, Too many open filesOne client per app/batch
async with asyncio.Semaphore(n) inside the workerNo limitingCreate once, share
Swallowing CancelledError in __aexit__Task cannot be cancelled; shutdown hangsReturn falsy; re-raise
Blocking I/O in __aexit__Event loop stallsasyncio.to_thread
Fire-and-forget create_task without keeping a referenceTask garbage-collected mid-run; errors lostKeep tasks in a set or TaskGroup, cancel on exit
asyncio.run() inside a running loop (Jupyter)RuntimeErrorawait directly
More in Python

python-beginner-concept-tutor

Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.

python-comprehension-refactorer

Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.

python-dataclass-converter

Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.

python-exception-handling-designer

Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.

python-idiomatic-code-reviewer

Review Python code for non-idiomatic, unpythonic or risky patterns and rewrite it into clean modern Python: PEP 8 / PEP 20, EAFP vs LBYL, iteration idioms (enumerate, zip, comprehensions, any/all), unpacking, f-strings, pathlib, truthiness, `is None`, mutable defaults, walrus, match statements, collections / itertools / functools, logging instead of print, with-blocks and resource handling. Includes AI-engineering checks: hardcoded API keys, time.sleep or requests inside async def, missing HTTP timeouts, unbounded retry loops, model clients built at import time, prompt string concatenation, sequential awaits, eval() on LLM output. Ships a 46-rule AST reviewer with severities and suggested rewrites.

python-type-hints-annotator

Add accurate, modern type hints to Python and fix mypy / pyright (pyrefly, ty) errors: builtin generics, X | None, TypeAlias and the 3.12 type statement, TypeVar and PEP 695 generics, Protocol, Callable / ParamSpec / Concatenate, TypedDict (Required, NotRequired, ReadOnly), Literal, Final, ClassVar, overload, Annotated, TypeGuard / TypeIs, Self, Never, TypeVarTuple, async types, Iterable vs Sequence vs Mapping, TYPE_CHECKING, stubs, py.typed, gradual strictness. AI-engineering focus: typed LLM message dicts and tool schemas, pydantic vs TypedDict, numpy NDArray, torch / jaxtyping shapes, streaming generators, async clients, callbacks, generic VectorStore protocols, typed config. Ships an AST audit: coverage gate, legacy-typing rewrites, inferred types.