Flat exception groups (alternative to PEP 654)

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 Results, 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:

  1. User runs the code, gets ConnectionResetError
  2. User adds try: ... except ConnectionResetError: ... to handle that case
  3. User runs the code again, the exact same ConnectionResetError happens again… but their except doesn’t run, because this time they lost the race and got an ExceptionGroup([ConnectionResetError, FileNotFoundError]), and that doesn’t trigger except 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 ExceptionGroups → therefore libraries are forced to use ExceptionGroups even for individual exceptions → therefore users end up getting ExceptionGroups 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 OSErrors 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 ExceptionGroups (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 a FileNotFoundError
  • 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 the ConnectionResetError handler should run
  • If FileNotFoundError is raised, then the user should get a FileNotFoundError 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 ExceptionGroups 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 ExceptionGroups; 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, set unhandled to the singleton set containing that exception.

  • Set raised to an empty set.

  • From top to bottom, for each except or except* clause:

    • If ExceptionGroup appears in list of exception types, raise a RuntimeError.

    • If this is an except* clause:

      • Scan over unhandled to find all exceptions that match the requested types, and remove them from unhandled.
      • Let current = ExceptionGroup([matched exceptions])
      • Set tstate->cur_exc to current
      • Bind the except* clause’s as variable (if any) to current
      • 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
      • Unset the as variable
    • If this is an except clause:

      • If the except clause matches BaseException (either because it’s explicitly mentioned, or because it’s a bare except:) AND len(unhandled) > 1:
        • Set matched = [ExceptionGroup([unhandled])] and clear unhandled (i.e., matched is a list containing one exception, where that exception happens to be an ExceptionGroup)
      • Otherwise:
        • Set matched = [exc for exc in unhandled if matches_this_clause(exc)], and remove these exceptions from unhandled
      • for match in matched:
        • Set tstate->cur_exc to match
        • Bind the as variable (if any) to match
        • 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
        • Unset the as variable
  • Once all except and except* clauses have been run:

    • Let raised += unhandled
      • If len(raised) == 1, then set tstate->cur_exc = raised[0]
      • If len(raised) > 1, then set tstate->cur_exc = ExceptionGroup(raised)
    • Run the finally block (if any)
    • If tstate->cur_exc is set, then continue unwinding

Notes:

  • For except + non-EG exceptions, this ends up producing the same behavior as classic try/except.

  • The except* behavior is identical to PEP 654 (I think)

  • The behavior of raise SomeError and raise ExceptionGroup([SomeError]) ends up being identical. The only way to distinguish between these is to explicitly peek at sys.exc_info() while an exception is in flight – and maybe raise should unwrap singleton ExceptionGroups 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. And ExceptionGroup is an exception, so it’s safe to bundle up all the remaining exceptions and pass them in together.

  • This means that except: and except BaseException: continue to run at-most-once, which reduces compatibility risks in existing code

  • This makes it possible to catch ExceptionGroup without using except* syntax, which is important for one very specific use case: writing six-style helpers that handle ExceptionGroup correctly on new Python and emulate it on old Python, without using new syntax like except*.

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 Exceptions. 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 Exceptions. With this version, the except Exception would catch ExceptionGroup([RuntimeError, KeyError]), and then the KeyboardInterrupt would continue propagating.

9 Likes

What happens if multiple of the exception handlers return? Does a return break out of this loop?

Excellent question! It’s a bit of a weird situation, because there might be uncaught exceptions in-flight in unhandled or raised. I don’t have a strong intuition about what’s “right”, but the situation is similar to this existing code:

try:
    # something that raises an exception
finally:
    return

Here there’s an uncaught exception in-flight during the finally block.

In current Python, the return wins, and the uncaught exception is discarded. So I guess we’d copy that for return inside except/except*? And likewise for break and continue.

I’m not super excited about this solution, but it’s enough of an edge case that it’s probably fine in practice – most people will never encounter it, and if you do encounter it there are enough clues to figure out what’s going on.

2 Likes

FYI, Irit, Yury and I (the authors of PEP 654) have posted a response to the SC tracker issue. For completeness here is the text we posted there:

We have read Nathaniel’s alternative proposal, and we believe that the two approaches are now clear and that they are unreconcilable. We would like your guidance on how to proceed.

The following are what we see as the main differences:

  1. Nathaniel proposes to change the semantics of (regular) try-except such that multiple except clauses can execute (multiple times). try-except is a decades-old feature which has similar semantics in other languages, and we don’t know how to evaluate the risks of (a) backwards compatibility breakage; (b) language ergonomics and predictability when breaking away from the semantics in other languages. We added except* because we assumed that such changes to except should not be considered. We would like a clear indication from the SC whether this aspect of the proposal should be discussed further.
  2. A primary design goal of Nathaniel’s proposal is to make it easy to iterate over an exception group. In an early draft of PEP-654, ExceptionGroup was iterable. We chose to remove that feature in order to de-emphasize iteration as the way to handle exception groups. (We believe that a correct usage pattern will need to do “if there were CancellationErrors in my async task, do X” rather than “for each CancellationError in my async task, do X”). Our point here is that the discussion about iteration is not about how to provide this capability – PEP 654 ExceptionGroup can provide an iteration API. The question is whether we should, and if it will turn out that we are wrong and this is useful, then adding it to a PEP-654 ExceptionGroup is a matter of implementing an iterator along the lines of the recipe we provide in the PEP.
  3. While the choice of data structure that the interpreter uses internally to represent an exception group is of secondary importance relative to questions of semantics, we wish to point out that Nathaniel did not discuss how the __context__ and __cause__ links of exception groups and the exceptions nested in them will be handled. They cannot be flattened like the __traceback__ s, and this will add major complications or limitations to his design. The whole point of ExceptionGroups is to make it possible to handle multiple exceptions without loss of error information. The integrity of the exceptions information, including the cause and context links, must be preserved for this to be a robust language feature. We do not believe that the exception group data structure, which is a tree that has meaningful context/cause information on its internal nodes, can be flattened to a list without loss of information.

Irit, Yury and Guido

1 Like

I really like the ergonomics of Nathaniel’s proposal. Provided the tracebacks enable the user to understand where and why each of the exceptions originated (especially those that were raised while handling other exceptions), it feels much more natural - but I get that the context & cause situation is exactly one of the sticking points raised by the PEP 654 authors (see point 3. above)

I think it would be very intuitive for except that each instance of an Error gets a corresponding exception (since it’s the most simple from the user’s POV - getting one exception per error). For users that then get flooded by 100x the same exception, they could easily find out that changing their code to except* allows them to handle all instances of the same error at once.

Can you specify that a bit? I mean, I can read your point 1. to see the argument why you didn’t consider changing the semantics of except, but now that it’s explicitly on the table, I don’t see how letting except run exhaustively on top of underlying tree-like EGs would be irreconcilable.

Something along those lines would allow the gradual learning curve Nathaniel is talking about (i.e. it’s easy to fall in the “pit of success” even with simple except), while leaving the full control for those who want to learn about except*.

This is challenging for sure. And I love stealing mature ideas, it’s fantastic when it works. But the problem here is… do you know of other languages that have succeeded at making concurrent error handling ergonomic? I don’t. So trying to stick close to prior art is also very risky.

Python’s async/await diverged substantially from prior languages, in that it doesn’t hard-code a Future concept into the language… and without this decision, trio wouldn’t exist, no-one would have heard of structured concurrency, and we wouldn’t be having this discussion. So that bet paid off! I also note that back in the day, Python’s threading APIs were closely inspired by Java, because Java was the state-of-the-art. But Java’s next-generation threading API is copying from Python, because apparently now we’re the most advanced. The cost of being at the head of the class is that you can’t copy other people’s homework :slight_smile:

Of course, none of this proves that this particular proposal is a good one. But I think we should consider it on the merits, not just dismiss it because it’s novel.

Hmm, sort of, but sort of not? It’s not that I think iterating over exception groups is the best way to work with them. It’s that… our users already understand lists, they use them all the time. If an exception group is basically just a list, then it empowers our users to figure out for themselves whether they want to iterate or not. In the PEP 654 approach, we can certainly use our expert understanding to write helpers that do the Right Thing for most users. But then non-expert users just have to copy-paste our examples and hope that they do the right thing; they can’t figure it out for themselves from first principles.

That’s true, I didn’t talk about __context__ and __cause__. Mostly because I don’t know what they would mean :-). I went to the store because I needed milk; you went to the airport to pick up your friend. What’s the cause of [I went to the store AND you went to the airport]? To me cause/context seem like properties of individual exceptions, not groups of exceptions.

What are you imagining PEP 654’s intermediate nodes would do with __context__/__cause__? Do you have a use case in mind?

(FWIW: In Trio, __context__/__cause__ on MultiErrors have just been a nuisance, because Python keeps trying to tack them on and creating reference loops and stuff, and we need to be robust against that. We don’t actually use them for anything, and if ExceptionGroup just hard-coded them to None that would be fine for us. We have considered potentially abusing __context__ on intermediate nodes to record preempted exceptions. But (a) this is a gross hack because it’s not what __context__ means, and the standard __context__ traceback formatting will be confusing, (b) in the flat exception groups approach, this problem is solvable by allowing richer traceback entries. I won’t go into more detail here because that would be like, it’s own PEP :-). But the point is that AFAIK __context__ support isn’t urgent, and we aren’t ruling out further extensions to support context-like features in the future.)

2 Likes

I’d like to share my perspective on the alternative semantics of try..except of the “flat exceptions” proposal. While working on PEP 654 we considered that option, as well as several variations of it. We hoped to be able to make except work with exception groups without the need for the new except* syntax, but came to the conclusion that this would not work well. We documented some of our thoughts about this in the PEP’s rejected ideas section, but it appears now that it is necessary to cover this in more detail, and I do that below.

Let me first outline the high-level difference between the proposals. With PEP 654, one can use the special except* syntax to handle exceptions raised from concurrent execution of asynchronous tasks:

try:
    async with TaskGroup() as g:
        ...
except *DatabaseNotAvailable as e:
    # This `except*` clause runs at most once; if it does,
    # `e` would be bound to an exception group containing instances
    # of `DatabaseNotAvailable`, e.g.:
    #
    #    ExceptionGroup(
    #       "", [DatabaseNotAvailable(...),
    #            DatabaseNotAvailable(...), ...])
    ...
except *SyslogNotAvailable as e:
    # This `except*` clause also runs at most once; `e` would be
    # similarly bound to a group of `SyslogNotAvailable` errors.
    ...

With the “flat exceptions” proposal you would have:

try:
    async with TaskGroup() as g:
        ...
except DatabaseNotAvailable as e:
    # This `except` clause can run multiple times; every run
    # `e` would be bound to a different `DatabaseNotAvailable()`.
    ...
except SyslogNotAvailable as e:
    # This `except` clause can also run multiple times.
    ...

With PEP 654:

  • Multiple except* clauses can be evaluated if a try: ... block fails. Every except* clause can be evaluated at most once.

  • It is prohibited to mix except and except* clauses in the same try block.

    The user has to explicitly choose if they want to use the classic try..except (which PEP 654 does not alter in any way) or try..except* (which is a new language construct).

With the “flat exceptions” proposal:

  • Multiple except clauses can be evaluated if a try: ... block fails. Every except clause can be evaluated more than once.

    The “flat exceptions” proposal changes the behavior of Python’s try..except block.

  • except* is also available and defined very similarly to PEP 654.

In my opinion, the “flat exceptions” proposal has serious flaws: lack of predictability and backwards compatibility issues.

Before we talk about these flaws in detail I’d like to discuss the usage pattern of the except* syntax proposed in PEP 654.

PEP 654: except* usage pattern

Quoting the “flat exceptions” proposal:

In summary: except doesn’t work with PEP 654 ExceptionGroups → therefore libraries are forced to use ExceptionGroups even for individual exceptions → therefore users end up getting ExceptionGroups all over the place, even in programs that never actually have multiple concurrent exceptions.

I think this is blowing it out of proportion.

I argue that the list of use-cases when one would need to reach for except* is short and distinct. The design of PEP 654 is informed by the simple fact that most of the actionable error handling is happening right where the potentially failing operation is performed.

Some examples:

  • Handling a KeyError around a dict operation allows to use a default value;

  • Handling an OSError around a block that calls low-level OS functions allows for accurate diagnostics or for trying an alternative code path;

  • Handling a library.DatabaseConnectionError around a block that opens and then uses a connection to a database allows to retry the operation; etc.

And here’s an example that does not make much sense:

try:
    async with TaskGroup() as g:
        for _ in range(jobs):
            g.create_task(start_job())
except *KeyError:
    # There is no context here to handle a KeyError here!
    # It should be handled in the `start_job()` implementation.

This does not make sense because when a set of concurrent tasks is running it is very hard to meaningfully interpret individual low-level errors. Correlating errors with individual asynchronous tasks is a lot of effort, which is better spent by moving the error handling logic inside those asynchronous tasks.

Which brings us to a simple set of rules:

  • Use try..except* around any API call that is explicitly documented to raise an ExceptionGroup, such as an asyncio TaskGroup or a Trio Nursery. This is the main motivation to add exception groups in the first place.

  • Use try..except* to intercept and react to control-flow errors like asyncio.CancelledError or KeyboardInterrupt. Typically this isn’t needed except in the application entry point and a few select places.

  • Use regular try..except in every other situation.

Lastly, libraries should not have APIs that “leak” ExceptionGroups to the user code. For the same reason as it would be a bug for a library like sqlalchemy to leak an internal KeyError to the user code. Libraries should instead handle exception groups and produce single and clear exceptions that callers can handle.

Lack of predictability

To get the obvious out of the way: many languages have a concept of try..except. It works more or less the same everywhere.

With the “flat exceptions” proposal an except clause might suddenly become a loop. This behavior will be unexpected for anybody who can read Python code but isn’t intimately familiar with this new feature.

Let’s construct an example to show how this can be confusing:

while True:
    try:
        async with TaskGroup() as g:
            for ids in groupped_ids:
                g.create_task(fetch_ids(ids))
        break
    except mydb.ConnectionError:
        await sleep(1)

The intent here is clear: if a database connection is interrupted in any of the tasks - wait 1 second and retry the entire operation. Most of the time everything would work as expected in the “flat exceptions” proposal, but some times two or more tasks would crash and the wait time would increase to two or more seconds.

The proposal does not give any visual clue for this behavior. The magic is implicit and requires people who read, write, and review code to always keep the new try..except semantics in mind.

This also affects the learning curve of the entire language. Quoting the proposal:

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.

Well, with PEP 654 there is no need to know about exception groups until you start to learn async/await or an API that produces them. The learning curve is incremental.

With the “flat exceptions” proposal, one would need to be aware that an except clause can run more than once and that will show up in the documentation, and in early examples and tutorials of even simple code (otherwise those examples would be misleading and teach unsafe practices.)

Backwards compatibility

Strictly speaking both proposals are backwards compatible. If you take an existing Python code and run it with a newer Python with either proposal implemented the code would work.

The actual issue is more subtle here. The proposed “flat exceptions” semantics of try..except can lead to sporadic unexpected errors or to a surprising and hard-to-track behavior.

Sporadic unexpected errors can be illustrated with a common pattern:

resource = create_resource()
try:
    await some_code(resource)
except ResourceError:
    resource.close()
    resource = None

With the “flat exceptions” semantics, this code can produce an AttributeError: 'NoneType' object has no attribute 'close' error from time to time.

With PEP 654, if some_code() propagates an ExceptionGroup this would fail with “Unhandled ExceptionGroup exception” error. The user will learn quickly that they need to switch to except*.

The other popular pattern is to have an except Exception clause in applications and frameworks to run error reporting and potentially some cleanup code. If the “flat exceptions” proposal is adopted, people would need to thoroughly audit code like that to make sure it is reenterable.

To illustrate the surprising and hard-to-track behavior, suppose that there’s error reporting in the above example:

resource = create_resource()
try:
    await some_code(resource)
except ResourceError as e:
    await report_error(e)
finally:
    resource.close()

With the “flat exceptions” proposal, if some_code() propagates an ExceptionGroup the function would run the except clause for every ResourceError error in it. If some_code() spawns a big number of tasks, error reporting might suddenly require more resources leading to all kinds of production problems: degraded performance due to excessive logging or to exceeding the error reporting API quota.

To summarize, I believe that these examples prove the point: the sudden change of the regular try..except semantics can be quite tricky to deal with, especially in complex code bases.

5 Likes

Just noting that the except/continue behaviour wouldn’t change in the flat exception groups proposal: return/break/continue control flow commands would interrupt the exception group handling, just as they interrupt finally clause execution today.

That said, Yury’s overall point regarding the riskiness of allowing existing exception handling clauses to run multiple times still stands.

FWIW, while I think the ergonomics of Nathaniel’s idea do sound potentially attractive, I’d personally side with the PEP 654 authors in considering it too great a compatibility risk compared to the more conservative “new syntax for new semantics” approach that PEP 654 takes.

1 Like

Just noting that the except / continue behaviour wouldn’t change in the flat exception groups proposal: return / break / continue control flow commands would interrupt the exception group handling, just as they interrupt finally clause execution today.

Good catch Nick, I’ve fixed that example by removing the continue command.

1 Like

Hey Yury, thanks for the thoughtful comments! I know it’s a lot of work to articulate these things, but I’m still optimistic that if we keep digging in we’ll at least understand the tradeoffs better, and hopefully even find a consensus. I’ve been working on a more detailed response about except semantics, but I’ve been struggling with health a bit this week again so it’s only ~3/4 done, sorry about that.

But while I’m finishing that up, a question – except semantics is clearly the thorniest part of PEP 654 vs flat EGs, but there’s also the somewhat separate question about which EG representation to use. In particular, flat EGs are simpler, but lose __cause__, __context__, and subclass typing for internal exception nodes. Are these things that you all still think are important? And if so, can you give some concrete examples of when they’re needed? Or would the flat representation + PEP 654 semantics for except be a viable option to consider?

(I’m replying for Yury & Irit here. All our responses have been jointly drafted.)

In particular, flat EGs are simpler,

We feel flat EGs have the following disadvantages:

  • they take up more space (because frames are duplicated);

  • the interpreter needs more time to update the traceback when adding a frame;

  • they require re-normalization for display.

but lose __cause__, __context__, and subclass typing for internal exception nodes. Are these things that you all still think are important? And if so, can you give some concrete examples of when they’re needed?

Yes.

For example, take a try block that run a group of tasks, where if any tasks fail, a further group of cleanup tasks has to be run. If some cleanup tasks fail, the resulting exception group needs context showing which of the original task group failed.