Handling Exceptions in aexit Without Swallowing Cancellation¶
__aexit__ decides the fate of every exception that leaves an async with block: return a truthy value and the exception disappears; raise, and your exception replaces it. In synchronous code that is mostly a question of style. In async code, the exceptions passing through include CancelledError and the cancellation that asyncio.timeout() uses internally — and a context manager that suppresses "everything" suppresses those too. In a test, a task whose async with used an over-eager suppressor was cancelled and kept running, finishing normally with cancelled() reporting False; wrapped in asyncio.timeout(0.01), the same suppressor turned an expired deadline into a block that exited normally with no TimeoutError. This guide covers what __aexit__ receives and how to suppress only what you mean to.
Prerequisites¶
- Python 3.11+, stdlib only.
- Context manager basics, from best practices for async context managers.
- How timeouts cancel, from choosing asyncio.timeout vs wait_for.
1. Know what aexit is given and what it returns¶
__aexit__(self, exc_type, exc, tb) receives the exception leaving the block, or three Nones on a normal exit. Its return value is only consulted when there was an exception:
- Falsy return (including the implicit
None): the exception continues to propagate. - Truthy return: the exception is suppressed and execution continues after the
async with. - Raising: the new exception propagates, with the original attached as
__context__.
class Suppressor:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return True # suppresses EVERY exception, including cancellation
async def main() -> None:
async with Suppressor():
raise ValueError("x")
print("ValueError suppressed") # this line runs
The "returns True" form is a loaded gun because exc_type can be asyncio.CancelledError, which since Python 3.8 is a BaseException, not an Exception. A check written as except Exception-style logic in __aexit__ — "suppress if it is an exception" — will not catch it, but a bare return True will.
Verify: run the snippet, then replace raise ValueError with await asyncio.sleep(10) and cancel the task from outside — it will not be cancelled.
2. See cancellation and timeouts disappear¶
The failure is easy to reproduce, and worth reproducing once so you recognise it:
async def work() -> str:
async with Suppressor():
await asyncio.sleep(10)
return "kept running after cancel"
async def main() -> None:
task = asyncio.create_task(work())
await asyncio.sleep(0.01)
task.cancel()
print(await task, task.cancelled()) # 'kept running after cancel' False
async with asyncio.timeout(0.01):
async with Suppressor():
await asyncio.sleep(1)
print("no TimeoutError") # the deadline was silently ignored
Both outputs were reproduced on Python 3.14. The timeout case is the more dangerous one: asyncio.timeout() works by cancelling the task and converting the resulting CancelledError into TimeoutError on the way out. If something inside suppresses the cancellation, the timeout context manager never sees it, and the code behaves as if the deadline had not expired — while having aborted whatever the block was doing. Callers then receive partial results presented as complete.
Structured concurrency suffers the same way: a TaskGroup cancels siblings when one fails, and a sibling that suppresses its cancellation keeps the group waiting.
Verify: search your codebase for __aexit__ implementations that return True without checking exc_type; each is a candidate for this bug.
3. Suppress only what you mean to¶
The fix is to suppress a specific, named set of exceptions — and never anything derived from BaseException alone:
class IgnoreDisconnect:
"""Treat a peer hang-up during a write as a normal end of stream."""
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb) -> bool:
if exc_type is None:
return False
if issubclass(exc_type, (ConnectionResetError, BrokenPipeError)):
log.debug("peer disconnected: %r", exc)
return True
return False # everything else, including CancelledError
For the common case, contextlib.suppress already does this correctly and works in both with and async with contexts as a plain context manager around awaits:
import contextlib
with contextlib.suppress(ConnectionResetError, BrokenPipeError):
await writer.drain()
contextlib.suppress(Exception) is safe with respect to cancellation, because CancelledError is not an Exception. contextlib.suppress(BaseException) is not.
Verify: cancel a task inside each of your suppressing context managers; task.cancelled() must be True afterwards.
4. Do not let cleanup failures hide the original error¶
When __aexit__ raises during exception handling, the new exception replaces the original. Python attaches the original as __context__, so a full traceback shows both — but code that catches by type only sees the new one:
class FailingExit:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
raise RuntimeError("cleanup failed")
try:
async with FailingExit():
raise ValueError("original")
except ValueError:
print("never reached") # the caller looked for ValueError
except RuntimeError as e:
print(repr(e.__context__)) # ValueError('original') is buried here
A connection that fails to close after a query error should not turn a UniqueViolationError into a ConnectionError — the caller's retry logic then retries something that will never succeed. Protect the original:
async def __aexit__(self, exc_type, exc, tb) -> bool:
try:
await self._conn.close()
except Exception as close_exc:
if exc is None:
raise # cleanup failure is the only error
log.warning("close failed during error handling", exc_info=close_exc)
return False # the original keeps propagating
If both errors matter to callers, raise them together as an ExceptionGroup — the approach covered in wrapping and re-raising ExceptionGroups.
Verify: make both the block and the cleanup fail; the caller must receive the block's exception type, and the cleanup failure must appear in logs.
5. Await cleanup safely when you are being cancelled¶
__aexit__ often needs to await — close a connection, roll back a transaction. If the block exited because of cancellation, those awaits run in a task that is being cancelled. With a plain task.cancel() they still run, because the cancellation has already been delivered. With asyncio.timeout() and TaskGroups, a second cancellation can arrive during cleanup and abort it half-way.
async def __aexit__(self, exc_type, exc, tb) -> bool:
if exc_type is not None:
try:
async with asyncio.timeout(2):
await asyncio.shield(self._conn.execute("ROLLBACK"))
except (TimeoutError, asyncio.CancelledError):
self._conn.terminate() # last resort: drop the connection
await self._pool.release(self._conn)
return False # the original exception, cancellation included, propagates
The shield lets the rollback finish even if another cancellation arrives; the timeout bounds how long cleanup may hold up cancellation; and terminate() guarantees a connection in an unknown transaction state never goes back to the pool. More on this in preventing CancelledError leaks in cleanup.
Verify: cancel during a slow query; the connection is either rolled back and returned, or terminated — never returned dirty.
Verification¶
Exception handling in __aexit__ is correct when:
- Cancelling a task inside the block always leaves
task.cancelled()true. asyncio.timeout()around the block raisesTimeoutErrorwhen the deadline passes.- The block's own exception type reaches the caller even when cleanup fails.
- Cleanup that awaits is bounded and cannot leave resources half-released.
Diagnostic Hook: log every suppression with the exception type at DEBUG and count it as a metric by context manager class. A suppression counter that ever records CancelledError or TimeoutError is a bug by definition; alert on it in staging. In production, a rising count of cleanup failures logged during error handling usually means a dependency is already unhealthy.
Pitfalls & edge cases¶
except BaseExceptioninside__aexit__to "log everything" then forgetting to re-raise.- Suppressing
GeneratorExit. In@asynccontextmanagergenerators, catching it and continuing raisesRuntimeError: generator didn't stop. - Returning a truthy non-bool by accident.
return self._closedwith a truthy value suppresses exceptions; return explicitFalse. - Re-raising
excyourself.raise excfrom__aexit__adds a new traceback frame and confuses the chain; returnFalseinstead.
Frequently Asked Questions¶
What does returning True from aexit do?
It suppresses the exception that left the async with block, and execution continues after the block. It applies to every exception type passed in, including asyncio.CancelledError, which is why an unconditional return True breaks cancellation.
Can aexit swallow asyncio cancellation?
Yes. If aexit returns a truthy value when exc_type is CancelledError, the task is not cancelled and keeps running. Inside asyncio.timeout this also hides the TimeoutError. Only suppress specific exception types.
What happens if aexit raises an exception?
The new exception replaces the one leaving the block and propagates to the caller, with the original attached as context. Callers that catch by the original type will miss it, so log cleanup failures instead of raising them when an error is already propagating.
Is contextlib.suppress safe with asyncio?
contextlib.suppress(Exception) and narrower types are safe, because CancelledError derives from BaseException. Never pass BaseException to it.
Related¶
- Async Context Managers & Iterators — up to the topic overview.
- Building async context managers with asynccontextmanager — the generator-based form and its own exception rules.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.