AsyncExitStack closes entered async contexts in reverse order when a later acquisition fails.
Make this comfortable
Python AsyncExitStack: unwind acquired resources after a later open fails
Operation contract
A local receipt workflow acquires a database lease and then a queue lease. The third acquisition rejects its input before entering. The stack releases the two acquired leases in reverse order, and the original ValueError remains visible to the caller.
Failure boundary
An async exit callback can itself fail or be cancelled; a real owner must decide how to retain cleanup failures and bound shutdown. This fixture records only in-memory events and does not open a database, broker, or network connection.
Working program
import asyncio
from contextlib import AsyncExitStack, asynccontextmanager
events = []
@asynccontextmanager
async def receipt_lease(name, *, reject=False):
if reject:
raise ValueError("lease rejected")
events.append("acquired:" + name)
try:
yield name
finally:
events.append("released:" + name)
async def prepare_receipt():
try:
async with AsyncExitStack() as resources:
await resources.enter_async_context(receipt_lease("database"))
await resources.enter_async_context(receipt_lease("queue"))
await resources.enter_async_context(receipt_lease("export", reject=True))
except ValueError:
print("acquisition_rejected", True)
print("events", events)
asyncio.run(prepare_receipt())Output
acquisition_rejected True
events ['acquired:database', 'acquired:queue', 'released:queue', 'released:database']Costs and limits
Each entered context adds one cleanup record, so retained stack state is linear in acquired resources. Unwinding invokes every exit callback; a slow close extends the failure path.
Common Mistakes
- Register cleanup as each acquisition succeeds, not only after all opens complete.
- Reverse release order matters when later resources depend on earlier ones.
- An in-memory unwind does not prove cancellation-safe cleanup of external services.
Connected lessons
python
async-exit-stack-partial-acquisition
