Dev Synthesis Lesson · Polyglot-Dev / Python · Sprint Track Python · Day 20

Python's Asynchronous Context Managers and Async Generators __aenter__ / __aexit__ Under Cancellation, nextLink Pagination, and the Finalization Problem

A with block promises cleanup on the way out. An async generator promises cleanup too, and declines to say when.

Filed: 2026-08-11 · Fajr anchor · trio #86
Sprint track: Python — day 20, seventh visit
Dev slot: async, lap 2 — the protocol layer beneath 07-27's structured concurrency
Paired Ops lesson: Bounded Concurrency for Python Ops Tools — asyncio Semaphores, the Connection-Pool Ceiling, and Fan-Out Across Azure Resource Manager
Paired Cert lesson: AZ-900 — the Azure Resource Hierarchy and the Governance Spine
Grounding: Ramalho, Fluent Python 2ed, Asynchronous Programming, pp. 816-850
Length: ~2,390 words · 4 code blocks
__aexit__ is the only promise
CancelledError is an exception. It unwinds, every enclosing finally runs, every __aexit__ is awaited. A permit taken with async with comes back. One taken with bare acquire/release does not.
Two suspensions, one object
An async generator parks at await waiting on I/O and at yield waiting on its consumer. From inside the body they look identical. From outside they are not.
The generator that never came back
A context manager's lifetime is bracketed by a statement. An async generator's is bracketed by nothing. Stop reading it early and its cleanup becomes the collector's schedule, not yours.
Never hold a resource across a yield. If a consumer can stop early, the generator gets aclosing.

*A with block promises cleanup on the way out. An async generator promises cleanup too, and declines to say when.*

§I — Frame

Today's Ops lesson leans on two guarantees it never proves.

The first sits in the worker: async with sem around a call that may be cancelled at any await point inside it, and the sweep's correctness depends on the permit coming back. The second is implied by the workload: four hundred subscriptions, each returning a paginated resource-group listing, and no version of that sweep should hold every page of every subscription in memory before processing the first one.

Both are protocols with an asynchronous half. async with is __aenter__ and __aexit__. async for is __aiter__ and __anext__. July's lesson took the scope layer, TaskGroup and the cancellation contract and the asyncio.timeout scope, and it took that layer well. Underneath the scope sit these two pairs, and one of them behaves in a way the scope layer cannot fix.

The July lesson could say what happens to a task when its group is cancelled. It could not say what happens to a paginator that a break statement walked away from. Those are different questions, and the second one has the worse answer.

§II — Language idiom: two protocols and one asymmetry

The asynchronous context-manager protocol.

Ramalho's treatment is compact and exact. A synchronous context manager implements __enter__ and __exit__; the asynchronous one implements __aenter__ and __aexit__, both coroutines, and async with awaits each in turn. The reason the async form exists at all is that setup and teardown are frequently I/O. Opening a connection, acquiring a distributed lock, and closing a session are all operations that should yield to the loop rather than block it.

The guarantee is the same guarantee try/finally gives, because that is what the statement compiles to. __aexit__ runs on the way out of the block by every exit path: normal completion, return, raise, and cancellation.

Cancellation is the case that matters here, and it is worth being precise about why it works. CancelledError is delivered at an await point as an exception. It is an exception. It unwinds the stack the way exceptions do, which means every enclosing finally runs, which means every enclosing __aexit__ is awaited. asyncio.Semaphore.__aexit__ calls release. So the permit comes back.

State it as the rule the Ops lesson borrowed: **__aexit__ is the only promise, and cancellation keeps it.** A permit acquired with async with is released on cancellation. A permit acquired with a bare await sem.acquire() and a matching sem.release() at the bottom of the function is not, because the cancellation arrives between them and there is no finally to catch it. That is not a style preference. It is a leak.

The asynchronous iteration protocol.

__aiter__ returns an asynchronous iterator, which implements __anext__ as a coroutine raising StopAsyncIteration when exhausted. async for drives it. Ramalho's asynchronous-generator sections cover the shorthand: put yield inside an async def and the function becomes an async generator, an object that is both awaitable at its await points and iterable at its yield points.

That dual suspension is the whole idea, and it is worth naming. Two suspensions, one object. An async generator parks at await when it is waiting on I/O and parks at yield when it is waiting on its consumer, and from inside the function body the two look identical. From outside, they are not: the first is the loop's business and the second is the caller's.

The asymmetry.

A context manager's lifetime is bracketed by a statement. The block ends, __aexit__ runs, done.

An async generator's lifetime is bracketed by nothing at all. It ends when it runs out of items, or when the consumer stops asking, and only the first of those two is an event the generator can observe. Stop iterating a generator that is suspended at a yield inside a try/finally, and that finally has not run. The generator is alive, suspended, holding whatever it was holding.

Python does eventually clean it up. The interpreter throws GeneratorExit in at finalization time, and asyncio installs shutdown hooks so that loop.shutdown_asyncgens() gets a chance to drain the survivors when the loop closes. Both of those are recovery mechanisms, and neither is a schedule. Coin the failure: the generator that never came back. Its cleanup runs when the garbage collector decides, possibly on a different tick, possibly after the session it wanted to close is already closed, possibly emitting a warning from a stack trace that points at nothing the author recognizes.

§III — Code: a paginated Azure lister that closes when you stop reading it

Azure Resource Manager paginates with a nextLink field: a page of results plus an absolute URL for the next page, absent on the last one. The natural expression is an async generator that yields items and fetches pages behind the consumer's back.

Start with a governor written as an asynchronous context manager, so the rate control from the Ops lesson composes with the semaphore instead of sitting beside it:

class RateWindow:
    def __init__(self, gate: asyncio.Event, floor: int):
        self._gate = gate
        self._floor = floor
        self.headers = None

    async def __aenter__(self):
        await self._gate.wait()
        return self

    async def __aexit__(self, exc_type, exc, tb):
        raw = (self.headers or {}).get("x-ms-ratelimit-remaining-subscription-reads")
        if raw is not None and int(raw) < self._floor and self._gate.is_set():
            self._gate.clear()
            asyncio.get_running_loop().call_later(20.0, self._gate.set)
        return False

Two things are deliberate. __aenter__ awaits the gate, so entering the block is where a throttled worker parks, which is a better place to park than an ad-hoc call at the top of a loop body. And __aexit__ returns False, which is the falsy value that tells Python not to suppress the exception passing through. Returning a truthy value from __aexit__ swallows exceptions, including CancelledError, and a context manager that swallows cancellation is a worker that cannot be stopped.

The paginator itself:

async def iter_resource_groups(session, sub_id, sem, gate, floor):
    url = f"https://management.azure.com/subscriptions/{sub_id}/resourcegroups"
    params = {"api-version": "2021-04-01"}
    while url:
        async with sem:
            async with RateWindow(gate, floor) as window:
                async with session.get(url, params=params) as resp:
                    window.headers = resp.headers
                    resp.raise_for_status()
                    body = await resp.json()
        for item in body.get("value", []):
            yield item
        url = body.get("nextLink")
        params = None

Read the nesting carefully, because the ordering is the design. The semaphore is outermost, so a permit covers one page fetch. The rate window is inside it, so a worker parked on the gate is holding a permit, which is the deliberate self-throttling choice the Ops lesson argued for. The response context manager is innermost and closes before any item is yielded, so the generator is never suspended at a yield while a live HTTP response is open. That last point is the one that keeps the whole thing safe: the generator holds no resource across a yield.

Compare it with the version an author writes first, which streams items out of the response object while the response is still open. That version parks at yield holding a connection from the pool. A consumer that breaks out of the loop leaves the connection checked out until finalization, and the Ops lesson's carefully-sized connector quietly loses a slot per abandoned iteration.

Now the consumer, and the line that matters:

from contextlib import aclosing

async def first_untagged(session, sub_id, sem, gate, floor, tag):
    async with aclosing(iter_resource_groups(session, sub_id, sem, gate, floor)) as groups:
        async for rg in groups:
            if tag not in (rg.get("tags") or {}):
                return rg["name"]
    return None

aclosing wraps the generator and calls aclose() on the way out of the block, which throws GeneratorExit into the suspended frame right there, at a known point, on the running loop. The return inside the async for is exactly the abandonment case, and without the wrapper it leaves a suspended generator behind for the collector to deal with on its own schedule.

The habit is small and worth making automatic: **if a consumer can stop early, the generator gets aclosing.** A generator that always runs to exhaustion does not need it. One consumed under a return, a break, an exception, or a timeout does.

There is a second-order effect worth stating. asyncio.timeout from July's lesson cancels the task, the cancellation unwinds through the async for, and the aclosing block finalizes the generator during that unwind. Without aclosing, the timeout fires, the task dies, and the paginator is still sitting there suspended, holding a semaphore permit that its own async with will release only when someone finalizes it. The Ops lesson's ceiling silently drops by one.

§IV — Connection to today's Ops lesson

The Ops lesson names three ceilings and argues that the lowest one wins. This lesson supplies the mechanism by which a ceiling gets lower than its configured value without anyone changing the configuration.

A permit is released by Semaphore.__aexit__. That release is guaranteed by exception unwinding, which is why cancellation is safe and why the sweep can be given a deadline without leaking its own concurrency. Take the async with away and put acquire/release around the same code, and the guarantee goes with it.

The pagination case is the sharper one. The sweep's worker was written to fetch one listing per subscription, which hides the problem. Rewrite it to stop at the first non-compliant resource group, which is what an audit tool actually wants, and every early return abandons a paginator mid-flight. Do that four hundred times under a semaphore of eight and the tool's effective concurrency decays run over run, for reasons no metric in the Ops lesson would catch. The header-reading governor sees nothing. The retry budget sees nothing. The sweep just gets slower.

aclosing is the whole fix, and it is one import.

§V — Prior-lesson reach

July's structured-concurrency lesson took the scope layer: TaskGroup as a boundary no task outlives, cancellation as a request delivered at an await point, asyncio.timeout as a scope that converts one into the other. Everything there holds unchanged. This lesson sits one level down and answers a question that layer leaves open, because an async generator is the one common asyncio object whose lifetime is not bound to the scope that created it.

Friday's attribute-protocol lesson made the argument that Python's magic methods are a contract the language calls on your behalf, and that knowing when the call happens is most of the skill. __set_name__ fires at class creation, __getattr__ fires only on lookup failure. Same shape here, and the same discipline of asking when: __aexit__ fires during unwinding, __anext__ fires per iteration, and finalization fires whenever nobody is holding the object any more. Two of those three are deterministic. The third is the one that bites.

The performance lesson from 08-05 named the cost of the wrong concurrency model and closed on composition: a TaskGroup over process-pool work, with the caution that cancellation does not cross the process boundary. The async-generator finalization problem is the same caution wearing different clothes. A guarantee that holds inside one mechanism stops holding at its edge, and the edge is where the operator has to know the difference.

§VI — Closing

Two dunder pairs, one asymmetry. __aenter__ and __aexit__ are bracketed by a statement and keep their promise through cancellation. __aiter__ and __anext__ are bracketed by a consumer's willingness to keep asking, and when that willingness ends early, cleanup becomes the collector's problem rather than yours.

Never hold a resource across a yield. Wrap any generator a consumer can abandon in aclosing. Return a falsy value from __aexit__ unless suppression is the point.

Open the async code you maintain and search it for async for. For each one, ask whether the loop can exit before the iterator is exhausted. Every yes is a generator waiting on the garbage collector.

Related

🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-11 · Fajr anchor · sprint track Python day 20 · seventh Python visit · trio #86

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-08-11 · Fajr anchor · sprint track Python day 20 · seventh Python visit · trio #86
Dev slot · LEO-LESSON-2026-08-11-dev · /rod-audited · topic re-selected after prior-art collision with 07-27