Skip to content

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

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.

What happens to the exception leaving an async with A decision on What does __aexit__ do with 3 outcomes. What happens to the exception leaving an async with What does __aexit__ do? returns None or False exception propagates the safe default returns True exception suppressed including cancellation raises new exception replaces it original in __context__ Only the first outcome is safe without inspecting exc_type first.

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.

A suppressing __aexit__ inside asyncio.timeout 3 lanes over time. A suppressing __aexit__ inside asyncio.timeout timeout block deadline 10 ms exits normally, no TimeoutError inner await sleep(1) interrupted __aexit__ returns True time → The timeout never sees its own cancellation, so the caller cannot tell the work was cut short.

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.

__aexit__ patterns and what reaches the caller A grid of 3 rows by 4 columns. __aexit__ patterns and what reaches the caller pattern block raises ValueError task cancelled cleanup fails too return True swallowed swallowed: bug swallowed suppress named types only propagates propagates cleanup error replaces it log cleanup error, return False propagates propagates original propagates The last row keeps both the caller's view and the cancellation contract intact.

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 raises TimeoutError when 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 BaseException inside __aexit__ to "log everything" then forgetting to re-raise.
  • Suppressing GeneratorExit. In @asynccontextmanager generators, catching it and continuing raises RuntimeError: generator didn't stop.
  • Returning a truthy non-bool by accident. return self._closed with a truthy value suppresses exceptions; return explicit False.
  • Re-raising exc yourself. raise exc from __aexit__ adds a new traceback frame and confuses the chain; return False instead.

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.