The steering council asked me to write up my thoughts on PEP 654 and the alternative design that I think should also be considered, so, here’s an attempt!
Flat exception groups
Philosophy
My favorite thing about Python is the smooth, incremental learning curve. Programming is complicated, and Python is a powerful and complicated language – but it doesn’t feel complicated, because the complexity is carefully arranged so you can start being productive right away with a minimal investment, and then learn more as you go, only when needed.
For example: the semantics of Python’s ubiquitous .
operator are extraordinarily complex. But you can get started with just:
# You can assign attributes to an object
some_object.my_attr = "hello world"
# You can read them back again
print(some_object.my_attr)
Later, you learn about @property
. Later, __getattr__
. And maybe, eventually, metaclass dunders and the difference between data and non-data descriptors and all that stuff. But at each point you can be productive, and the only reason to move onto the next step is if it helps you solve some problem. It’s like how in C++ people say “you only pay for what you use”, except in C++ they’re talking about runtime overhead, and in Python it’s conceptual overhead.
Python’s error handling is similar: it’s very common to start out by writing code that only handles the happy-path, and then add error handling incrementally when you actually see exceptions. This is very different from, say, Java’s checked exceptions or Rust’s fully-typed Result
s, where the language forces you to think about all the errors that could happen up front. These languages are also great! But they’re not Python.
This is dear to my heart; my goal with Trio has been to extend this Python philosophy to concurrency. With Trio, if you know that await fn()
is the way to call async functions and how to use a nursery, then that’s already enough to write useful scripts. Of course there’s a ton more sophistication there when you need it, but the minimum investment is very small, and (hopefully) everything past that is a series of bite-size pieces.
PEP 654
Error handling in concurrent programs is necessarily a gnarly, complex topic, and experts are always going to have to deal with that. My concern with PEP 654 is that it forces users to confront the potential gnarliness up-front, before they need it.
First off: the point of exception groups is to be able to handle the case where concurrent code raises multiple exceptions simultaneously. But with PEP 654, if concurrent code raises just one exception, then asyncio/trio will still wrap it into an exception group. That’s because we’re worried about code like this:
# Run 'child1' and 'child2' concurrently:
async def parent():
async with trio.open_nursery() as nursery:
nursery.start_soon(child1)
nursery.start_soon(child2)
# Simulate two tasks that can independently fail due to external conditions
async def child1():
if coin_flip():
raise ConnectionResetError
async def child2():
if coin_flip():
raise FileNotFoundError
This code might end up raising ConnectionResetError
, FileNotFoundError
, or both at once. So if asyncio/trio let solo exceptions propagate normally without wrapping, you could have:
- User runs the code, gets
ConnectionResetError
- User adds
try: ... except ConnectionResetError: ...
to handle that case - User runs the code again, the exact same
ConnectionResetError
happens again… but theirexcept
doesn’t run, because this time they lost the race and got anExceptionGroup([ConnectionResetError, FileNotFoundError])
, and that doesn’t triggerexcept ConnectionResetError
.
So now we’ve accidentally tricked the user into adding the wrong exception handling code – they need to go back and replace it with except*
at least.
OTOH, by making wrapping unconditional, in the first step the user gets ExceptionGroup([ConnectionResetError])
, so they’re alerted up-front that they might have to deal with multiple exceptions in the future. This is the same idea as Java’s checked exceptions: we’re going to force you to be prepared for things that haven’t happened yet, just in case. It’s not ideal, but it’s still better than actively sending you down the wrong path.
In summary: except
doesn’t work with PEP 654 ExceptionGroup
s → therefore libraries are forced to use ExceptionGroup
s even for individual exceptions → therefore users end up getting ExceptionGroup
s all over the place, even in programs that never actually have multiple concurrent exceptions.
And then, once you get an ExceptionGroup
, you need to know a lot to handle it appropriately. You need to understand:
-
what the internal nodes in the tree mean. (Usually the structure just represents the path the exceptions took, like traceback data, and only the leaves are interesting. But sometimes the internal nodes reflect semantic information, e.g. if a library subclasses
ExceptionGroup
to give the internal nodes custom types.) -
the different options for iterating over an
ExceptionGroup
, and how to choose between them -
how traceback information is stored (it’s spread out across the whole tree, so working with individual exceptions inside the tree – e.g. by re-raising them or accessing their
__traceback__
attributes – will usually produce incomplete or corrupted traceback info) -
when to use
except SomeErrorType
,except ExceptionGroup
,except*
(all three are supported, even if the first two are usually not helpful, so before you can write any of them you need to understand the pitfalls so you can pick the right one)
Again, the problem isn’t that PEP 654 can represent these details – these all reflect real complexities of the underlying domain, and experts will want to understand them regardless. The problem is that these details are front-and-center for all users whether they need them or not.
An alternative approach: “flat” exception groups
The core idea proposed here is to “denormalize” tracebacks, making “flat” exception groups (versus PEP 654’s “nested” exception groups). Every time an ExceptionGroup
traverses a stack frame, that stack frame gets appended to all of the individual exceptions’ __traceback__
s, rather than just a single ExceptionGroup.__traceback__
. Of course we’ll renormalize when printing tracebacks, to avoid overwhelming the user with the same stack frames over and over – we have all the same data, we’re just using a different intermediate representation.
Implementation-wise, this is complicated by the way the interpreter’s unwinding code builds tracebacks in tstate->curexc_traceback
, and only writes them back to exc.__traceback__
when the exception is caught. But this is easy to fix: just special case the writeback code (e.g. PyException_SetTraceback
) to detect ExceptionGroup
objects and pass through the writes to the exceptions inside them, instead of storing the traceback on the EG object itself. For example, exception groups could have the C equivalent of:
@property
def __traceback__(self):
return None
@__traceback__.setter
def __traceback__(self, new_frames):
for exc in self:
exc.__traceback__ = concat_tb(new_frames, exc.__traceback__)
You can also imagine optimizations, like deferring the traceback update until someone actually accesses a __traceback__
attribute. But in any case, all this is invisible to users: from the Python level, the rule is just that every exception in an exception group has an appropriate __traceback__
.
By itself, this denormalization is a small change; but its ripples spread out and affect every other aspect of the design. Now that internal nodes aren’t needed to hold traceback information, exception group objects can be a single flat list, which means there’s just one obvious way to iterate them. And if you do, it’s safe to work with the individual exceptions without any special precautions. So this immediately removes a bunch of the concepts that users would have to learn with PEP 654.
And, it doesn’t actually reduce expressiveness at all: Say you have a library that really wants to bundle up a group of exceptions into a single meaningful exception – like a HappyEyeballsError
that holds multiple OSError
s representing individual connection attempts; a HypothesisError
that holds multiple failures for different randomly generated test cases. The library can still do that explicitly with code like raise HypothesisError from ExceptionGroup([exc1, exc2, ...])
. Now the nested exceptions will automatically be included in tracebacks, and are accessible to handling code if desired. But this way the tree structure only occurs in cases where it’s actually meaningful to the user, and users who don’t need this kind of tree structure never see it at all.
But that’s not all: since the exceptions now have self-contained metadata, it becomes possible to give plain except
useful semantics (which I’ll discuss in their own section below). And if plain except
can do something useful with exception groups, then asyncio/trio aren’t forced to wrap solo exceptions! Most of the time, programs don’t experience multiple simultaneous exceptions, so this hugely reduces how often users are exposed to ExceptionGroup
s (I’d guess by maybe two orders of magnitude?) – and it means that when users do finally see an ExceptionGroup
, it’s actually relevant to them, because it means their program actually has multiple exceptions raised simultaneously.
Of course, this depends on except
having useful default behavior with exception groups, which is the most complicated and controversial part of the proposal, so it gets its own section.
Exception groups and except
Recall our example above, where a user attempted to use a regular except
to catch a ConnectionResetError
from concurrent code:
async def parent():
try:
async with trio.open_nursery() as nursery:
nursery.start_soon(child1) # might raise ConnectionResetError
nursery.start_soon(child2) # might raise FileNotFoundError
except ConnectionResetError as exc:
print(f"Connection lost: {exc!r}")
The core intuition is that:
- If the block raises
ConnectionResetError
, then the message should be printed and then the program should terminate normally. - If the block raises
FileNotFoundError
, then the program should terminate with aFileNotFoundError
- If the block raises both exceptions, then the message should be printed and the program should terminate with a
FileNotFoundError
.
Or put another way:
- If
ConnectionResetError
is raised, then theConnectionResetError
handler should run - If
FileNotFoundError
is raised, then the user should get aFileNotFoundError
and traceback.
And if they do get a FileNotFoundError
, then they can modify their program, in the obvious way:
async def parent():
try:
async with trio.open_nursery() as nursery:
nursery.start_soon(child1) # might raise ConnectionResetError
nursery.start_soon(child2) # might raise FileNotFoundError
except ConnectionResetError as exc:
print(f"Connection lost: {exc!r}")
except FileNotFoundError as exc:
print(f"File not found: {exc!r}")
…and now either or both messages might be printed, depending on what the child tasks do.
So the big change here is that except
handles ExceptionGroup
s by running all matching clauses, as many times as necessary until all handleable exceptions have been handled.
This is a substantial change to except
's invariants, and I expect we’ll have a lot more discussion about it :-). But I think it’s justified given that:
-
This only affects code that starts raising
ExceptionGroup
s; existing programs are completely unaffected. -
If you do have an
ExceptionGroup([ConnectionResetError, FileNotFoundError])
, then this behavior is surprising, but every other behavior would be even more surprising -
For existing code, these semantics won’t be correct 100% of the time, but I think they’ll they’ll be correct more often than PEP 654’s semantics. (In PEP 654, existing handlers will never run on
ExceptionGroup
objects, even if it would make sense.) -
For new code, this makes it easier to fall into the “pit of success”, as in our example – the first thing Python programmers try will work.
I think we do still want except*
, mostly for cases where you want to print a traceback: catching a whole group at once lets you print better tracebacks, because you can merge duplicated parts. Maybe also for cases where you specifically want to handle a specific combination of exceptions in a special way? But in this proposal except*
becomes much less emphasized.
Detailed semantics
(Treat this section as a first draft – it’s detailed for concreteness, but I’ve mostly been focused on the overall concepts so this isn’t super polished.)
Here’s the basic type – it’s basically an immutable list that is also a BaseException
. Not much going on here:
class ExceptionGroup(BaseException, collections.abc.Sequence):
def __init__(self, excs):
self._exc= []
for exc in excs:
if isinstance(exc, ExceptionGroup):
self._excs += exc.excs
elif isinstance(exc, BaseException):
self._excs.append(exc)
else:
raise TypeError
# No subclassing -- this is a pure carrier for other exceptions, and has no semantics
# beyond that, so subclassing doesn't make sense.
def __init_subclass__(self):
raise TypeError
# Acts as an immutable sequence
def __len__(self):
return len(self._excs)
def __getitem__(self, idx):
return self._excs[idx]
# Tracebacks are stored on the contained exceptions
@property
def __traceback__(self):
return None
@__traceback__.setter
def __traceback__(self, tb):
for exc in self.excs:
exc.__traceback__ = concat_tb(tb, exc.__traceback__)
# todo: what to do about __context__/__cause__?
And then the semantics of try
/except
/except*
:
After running the try
block, if it raised an exception:
-
Set
unhandled
to the exceptions in the exception group; or, if this is a regular non-exception-group exception, setunhandled
to the singleton set containing that exception. -
Set
raised
to an empty set. -
From top to bottom, for each
except
orexcept*
clause:-
If
ExceptionGroup
appears in list of exception types, raise aRuntimeError
. -
If this is an
except*
clause:- Scan over
unhandled
to find all exceptions that match the requested types, and remove them fromunhandled
. - Let
current = ExceptionGroup([matched exceptions])
- Set
tstate->cur_exc
tocurrent
- Bind the
except*
clause’sas
variable (if any) tocurrent
- Run the body of the
except*
clause - If this raises an exception:
- If it’s a group, append the contents to
raised
- Otherwise, append the single exception to
raised
- If it’s a group, append the contents to
- Unset the
as
variable
- Scan over
-
If this is an
except
clause:- If the
except
clause matchesBaseException
(either because it’s explicitly mentioned, or because it’s a bareexcept:
) ANDlen(unhandled) > 1
:- Set
matched = [ExceptionGroup([unhandled])]
and clearunhandled
(i.e.,matched
is a list containing one exception, where that exception happens to be anExceptionGroup
)
- Set
- Otherwise:
- Set
matched = [exc for exc in unhandled if matches_this_clause(exc)]
, and remove these exceptions fromunhandled
- Set
-
for match in matched:
- Set
tstate->cur_exc
tomatch
- Bind the
as
variable (if any) tomatch
- Run the body of the
except
clause - If this raises an exception:
- If it’s a group, append the contents to
raised
- Otherwise, append the single exception to
raised
- If it’s a group, append the contents to
- Unset the
as
variable
- Set
- If the
-
-
Once all
except
andexcept*
clauses have been run:- Let
raised += unhandled
- If
len(raised) == 1
, then settstate->cur_exc = raised[0]
- If
len(raised) > 1
, then settstate->cur_exc = ExceptionGroup(raised)
- If
- Run the
finally
block (if any) - If
tstate->cur_exc
is set, then continue unwinding
- Let
Notes:
-
For
except
+ non-EG exceptions, this ends up producing the same behavior as classictry
/except
. -
The
except*
behavior is identical to PEP 654 (I think) -
The behavior of
raise SomeError
andraise ExceptionGroup([SomeError])
ends up being identical. The only way to distinguish between these is to explicitly peek atsys.exc_info()
while an exception is in flight – and mayberaise
should unwrap singletonExceptionGroup
s to make them fully identical?
The special case for except BaseException
deserves more discussion. The rationale is:
-
except BaseException
is saying that it can handle any kind of exception; it doesn’t make any assumptions at all about the exception type. AndExceptionGroup
is an exception, so it’s safe to bundle up all the remaining exceptions and pass them in together. -
This means that
except:
andexcept BaseException:
continue to run at-most-once, which reduces compatibility risks in existing code -
This makes it possible to catch
ExceptionGroup
without usingexcept*
syntax, which is important for one very specific use case: writingsix
-style helpers that handleExceptionGroup
correctly on new Python and emulate it on old Python, without using new syntax likeexcept*
.
Possible extension: We could also steal the idea of PEP 654’s BaseExceptionGroup
/ExceptionGroup
split, where the two types are identical except that ExceptionGroup
is guaranteed to contain only Exception
s. And then we could extend the except BaseException
special case to also apply to except Exception
. The advantage would be to improve backwards-compatibility by giving except Exception
at-most-once semantics. In fact, we could do slightly better than PEP 654 here. Consider this code:
try:
raise BaseExceptionGroup([KeyboardInterrupt, RuntimeError, KeyError])
except Exception as exc:
print(f"Squashing boring exception: {exc!r}")
With PEP 654 semantics, the presence of the KeyboardInterrupt
turns the whole exception into a BaseExceptionGroup
, so the except Exception
doesn’t catch the Exception
s. With this version, the except Exception
would catch ExceptionGroup([RuntimeError, KeyError])
, and then the KeyboardInterrupt
would continue propagating.