contextlib: ExitStack Silently Drops All But One Callback Error (tested)
contextlib.ExitStack lets you register several cleanup callbacks and runs them all on exit, even if one of them raises. That sounds like it would behave like exception chaining elsewhere in Python -- each new exception remembering the one before it as __context__. We measured what actually happens when two callbacks both raise.
1. Two callbacks, both raising -- only one exception comes out
import contextlib
def cb1():
raise ValueError("cb1 failed")
def cb2():
raise TypeError("cb2 failed")
try:
with contextlib.ExitStack() as stack:
stack.callback(cb1)
stack.callback(cb2)
except Exception as e:
print(type(e).__name__, e)
ValueError cb1 failed
Measured: cb1 was registered first but ExitStack runs callbacks in reverse (LIFO) order on exit, so cb2 actually executes first, then cb1. The exception that propagates is cb1's -- whichever callback runs last wins, overwriting whatever exception the previous callback(s) raised.
2. The lost exception isn't chained anywhere -- it's just gone
import contextlib
def cb1():
raise ValueError("cb1 failed")
def cb2():
raise TypeError("cb2 failed")
try:
with contextlib.ExitStack() as stack:
stack.callback(cb1)
stack.callback(cb2)
except Exception as e:
print("context:", e.__context__)
print("cause:", e.__cause__)
print("suppress_context:", e.__suppress_context__)
context: None
cause: None
suppress_context: False
Measured: normal Python exception handling chains a new exception to whatever was being handled when it was raised -- that's exactly what __context__ is for. Here it's None. cb2's TypeError("cb2 failed") doesn't appear in __context__, __cause__, or the printed traceback. It isn't suppressed on purpose (__suppress_context__ is False) -- the information is simply never captured in the first place, because ExitStack.__exit__ re-enters its own cleanup loop for each callback and only the final exception it's holding at the end gets raised.
3. The same thing happens when a callback error competes with the block's own exception
import contextlib
def cb1():
raise ValueError("cb1 failed")
try:
with contextlib.ExitStack() as stack:
stack.callback(cb1)
raise KeyError("the real bug")
except Exception as e:
print(type(e).__name__, e)
print("context:", e.__context__)
ValueError cb1 failed
context: the real bug
Measured: here the original KeyError at least survives as __context__ -- this case goes through the normal "new exception raised while handling another" path, since the callback runs while KeyError is already propagating. The silent total loss from repro 2 is specific to multiple callbacks failing against each other, with no original exception from the block itself to anchor the chain.
Catch and collect inside each callback if you need every failure
import contextlib
errors = []
def safe_cb(fn):
try:
fn()
except Exception as e:
errors.append(e)
with contextlib.ExitStack() as stack:
stack.callback(safe_cb, cb1)
stack.callback(safe_cb, cb2)
if errors:
raise ExceptionGroup("cleanup failed", errors)
Wrapping each callback so it records its own exception instead of raising is the only way to see every failure -- letting ExitStack propagate the raw exception means you only ever find out about one of them, and which one depends on registration order.
Frequently asked questions
What happens if two ExitStack callbacks both raise an exception?
Only one exception propagates out of the with statement -- we measured the others vanishing completely, with no trace in __context__, __cause__, or the traceback.
Which callback's exception wins when multiple callbacks fail?
The one that runs last -- we measured ExitStack running callbacks in LIFO (reverse-registration) order, so the exception from the callback registered first (which therefore executes last) is the one that propagates.
Is the lost exception chained as __context__ like normal exception handling?
No -- we measured __context__ and __cause__ both None and __suppress_context__ False, meaning the information wasn't deliberately suppressed, it was simply never captured.
Does the original exception from inside the with block survive if a callback also fails?
Yes, in that specific case -- we measured the block's own exception surviving as __context__ when exactly one callback also raises. The total silent loss only happens when multiple callbacks fail against each other with no exception already propagating from the block.
Written with AI assistance. Measured on 2026-10-08 with Python 3.13.5's built-in contextlib module: registered two callbacks on an ExitStack that each raise a different exception, and inspected what actually propagates plus its __context__/__cause__. PlainHeaders is our extension; Python's contextlib module is built in.