Can I step back for a moment and say I’m getting a weird vibe here? Like, the actual conversations are apparently happening in some backchannel where I’m not allowed to participate, and all your jointly-crafted public posts are rigorously on-message and never acknowledge any downsides to PEP 654 or upsides to alternatives. I don’t know if you’re angry, or frustrated, or feel blindsided, or aren’t interested in spending energy on improving a “good enough” solution, or are just trying to perform the political games required to get proposals through python-dev’s toxic culture, or what. But I end up feeling like at some point you decided that I am actually some kind of enemy trying to waste your time with impractical nonsense.
I promise, I’m not here to try to attack you or waste your time or anything. All I wanted, and all I want, is to collaborate with my friends and come up with beautiful code where we’re satisfied we’ve picked the best tradeoffs. I don’t care if that’s PEP 654, or my proposal at the top of this thread, or some compromise, or what. I’ve been trying to discuss this stuff with you since October last year, and I’m tired of fighting about it too. I don’t know if I offended you somehow. If I did I’m sorry. Is there any way I can make amends, or repair the relationship, or something? Because this is miserable, and I don’t think it’s good for Python either. Can we talk offline, maybe do a call?
I agree, these are disadvantages. There’s a lot of room to optimize the frame duplication’s extra space/time costs – I think they’ll end up negligible in real programs. (If you disagree, I’d love to see your reasoning – maybe I missed something.) But it’s true they’re not zero. And renormalization does require some extra code when displaying tracebacks – it’s a pretty simple linear-time algorithm, just a classic prefix trie construction, and very few users write their own traceback printing code from scratch, but again, the cost isn’t zero.
But the question isn’t “do flat exception groups have costs”; it’s about weighing the tradeoffs. To me, the big thing is: I’ve spent a lot of time helping confused beginners making their first steps into writing concurrent programs, and based on that, I’m confident I can explain how to use flat exception groups, but I don’t think I can do that with PEP 654 exception groups. I’m very willing to accept some minor runtime penalties and a bit of extra code in the traceback printing libraries if it makes Python easier to “fit in your head” for all users.
I wonder if part of the difference in attitude here is coming from asyncio’s history? Asyncio is excellently designed for its era, but I think everyone agrees that writing asyncio programs is still radically more difficult than writing regular synchronous Python programs. At the time asyncio was designed, we just didn’t know how to do better than that – “easy to use” for concurrency libraries meant “possible to use correctly if you’re an expert and think really hard”. Against that background, the complex structure of nested exception groups + the attitude of “you don’t have to understand it, just use the tricky helpers someone we wrote for you” makes a lot of sense. [Edit after seeing the latest SC post: I guess this perspective is also implicit in Thomas’s comment that EGs are addressing a rare/niche use case.]
My perspective is more optimistic: concurrency is ubiquitous in the real world – we all multitask, and split up work (“you wash and I’ll dry”), it’s so natural that non-programmers take it for granted. I think the main reason programmers consider concurrency such an advanced topic is mostly not because it’s intrinsically impossible, but because our tools have always been so low-level and difficult to use. I believe we can make concurrent Python “fit in your head” almost as well as regular Python. Of course I don’t want to force concurrency on anyone, or compromise Python’s usability for sequential use-cases; but I think we can make the step from sequential to concurrent programming much, much more approachable than it is now. So I think it’s really important to make EGs “fit in your head” as much as possible, however that’s accomplished.
I’m not not attached to my exact proposal in all its details, but I do think we can find ways to simplify PEP 654 quite a bit without losing anything important.
Ah, yeah, this case worried me too! For concreteness, we’re talking about code like:
try:
...
except ...:
# Concurrent cleanup version:
async with io_lib.open_nursery() as nursery:
nursery.start_soon(cleanup)
...
# Sequential cleanup version:
await cleanup()
...
In both of these cases, if cleanup()
raises an exception then it’s really nice to get the original exception attached to it as __context__
.
However… we already have a good place to put that info, in the __context__
on the original exception. That’s where it goes with the sequential cleanup case, and it’s still available in the concurrent cleanup case. So with nested EGs, I think what you’re talking about is actually a second redundant place to store this same information?
And it’s kind of an awkward place, in the middle of the second exception’s traceback. Consider a case where exception A leads to exception B leads to exception C. Right now you get:
[traceback for exception A]
While handling this exception, another exception occurred:
[traceback for exception B]
While handling this exception, another exception occurred:
[traceback for exception C]
and it’s represented as C.__context__ → B
, B.__context__ → A
.
If we use the EG’s __context__
, then we have an ExceptionGroup
holding:
- half of
C
’s traceback
- a
__context__
pointing to A
- and as payload, the exception
C
, which holds:
- a
__context__
pointing to B
- the other half of C’s traceback
I’m not sure how you untangle that to produce the nice linear printed output we want.
That said, it’s true that setting the leaf exceptions’ __context__
is a bit awkward right now, but only because of an unrelated limitation in Python’s exception handling system: it’s not easy for asyncio/trio to propagate excinfo
into new tasks, so __context__
propagation currently doesn’t work automatically across task boundaries. But, this is fixable, and then I think that would be the superior approach even with nested EGs.
So I think this is actually an example where nested EGs are slightly worse than flat EGs: they provide this extra representational option (__context__
on intermediate nodes), but it turns out to be just a red herring.
Hmm. I think you misunderstood my argument. I’m not saying “except*
will be used a lot, so requiring it will make code complicated”. I actually agree with everything you wrote here; except*
is only needed in relatively rare circumstances. But, this doesn’t mean users will automatically know when except*
is irrelevant – they still have to figure that out themselves in each case.
With PEP 654, exception groups are ubiquitous, so users will see them and have to look them up to figure out what’s going on, and they’re hard to understand, so users will have to wrap their head around red herrings like except *
, and except ExceptionGroup
before they figure out that actually all they want is a regular except
close to where the exception is raised.
Probably the worst part of this is allowing except ExceptionGroup
– I see people trying to use this all the time with Trio’s current EG-equivalent, and it’s never what they actually want. Even if except
doesn’t loop, it would still help to make except ExceptionGroup
an error – then it’s at least obvious that your options really are except*
for ExceptionGroup
s or except
before you have an ExceptionGroup
. (And the other simplifications of flat EGs are also still helpful.)
Have you seen the paragraph at the very end of my first post, the one that starts “Possible extension:”? I skimmed over it pretty quickly so it was easy to miss, but flat EGs can use the same trick that was added to PEP 654 for improved compatibility with existing except Exception
clauses specifically. It’s not as obviously necessary for flat EGs as it is for nested EGs, but it’s still available and works just as well.
Yeah, this concerned me too, but once I thought through the details I think it’s actually fine. By assumption, your system has the resources to run a large number of tasks, and then unwind them all. Calling looping over report_error
just adds a small extra amount of work for each task.
Put another way: if calling report_error
for each leaf exception is prohibitively expensive, then it’s also prohibitively expensive to put try
blocks inside individual tasks – they do the same thing in the end. But we don’t worry about that cost, so we shouldn’t worry this cost either.
Huh, this is a fascinating point! The first time I read this, I was like “oh whoa that’s an important insight, hmmmmm what do I make of it”. And then the second time I read it I was like “wait a second, if we take what Yury wrote literally then it makes no sense at all”. Which is a weird dichotomy!
Like, if you read the actual quote above carefully, maybe you’ll see what I mean. Yury points out – correctly – that with with PEP 654, if you never use an API that produces exception groups, then you can get along just fine without knowing them. But with my proposal, on the other hand… if you never use an API that produces exception groups, then again, you can get along just fine without knowing anything about them. Like, by definition, right? If we start by assuming EGs never happen, then you never need to deal with them; if we start by assuming that they do happen, then you do need to deal with them. This isn’t a difference between the proposals at all!
So I think there’s an important insight here, but it’s something more nuanced that we haven’t quite articulated yet. Which is cool! That usually means we’re learning something. I’ll take a stab at trying to draw out Yury’s comment into something more concrete – Yury, lmk if these cover what you’re thinking or not?
One argument I thought about while reading Yury’s comment: With the flat EG proposal, ExceptionGroup
s will be easier to use and more-integrated with the language. So, people will use them more than if they’re hard to use and quarantined off in the async-only box. And that means people will encounter them sooner, and that ends up making Python harder to learn.
Or put another way: EGs always make APIs worse, and should only be used if absolutely necessary. But with flat EGs the downsides are hidden better, so API designers won’t notice the problems until it’s too late, while PEP 654 EGs make their downsides more obvious so API designers will naturally shun them.
This is an interesting argument, and I keep going back and forth on it. Like – at some level yeah obviously, all else being equal, nice features are used more than awkward features But it also feels weird to argue for a feature because it’s harder to use and makes APIs rigid and harder to refactor! Neither proposal forces APIs to raise EGs – either way we can have the same documented conventions about when they’re appropriate (“it’s for concurrency, don’t use it just to be cute”) and API designers will have the same options for avoiding exposing them (“prefer raise MyLibraryError(...) from EG(...)
whenever it makes sense”). And either way, they only show up if an API designer consciously decides that using them will produce a better API than not using them.
My intuition is that there are two kinds of developers out there:
-
Ones who like to experiment with exotic features and will raise EGs no matter what, just because they can. Their downstream users are going to have to learn about and cope with EGs no matter what design we use. Fortunately, these kinds of libraries don’t tend to see widespread usage.
-
Ones who are smart enough to read the docs and avoid using EGs unless it really is a good idea. For these, the most important thing is good docs and clear use cases, and flat EGs simpler and more focused design might help with that?
It’s especially weird that PEP 654 both takes the position that APIs should not raise ExceptionGroup
s unless absolutely necessary, and talks up the possibility of doing class HypothesisError(ExceptionGroup)
. In my proposal, that’s not even possible – you have to do raise HypothesisError from ExceptionGroup
instead. So these two aspects of PEP 654 seem to contradict each other?
Overall I’m feeling like this topic is something to keep in mind, but it doesn’t provide much clear guidance for any specific technical questions.
Another argument I thought about while reading Yury’s comment: There are two kinds of exceptions. The ones that you expect – they happen in some well-defined situation, you know about that situation, you write code to handle it appropriately. For these, it doesn’t really matter whether except
has automatic looping or not – if you’re explicitly writing code to handle an ExceptionGroup
then you’ll pick the right tools for the job.
But then there are the exceptions that catch you by surprise. Your program runs into some situation that never even occurred to you as a possibility, and things start falling apart. Of course, there’s no way to handle these situations 100% reliably – by definition, your program is now in some unknown state that you don’t understand. But it’s still worth trying to do some kind of last-ditch recovery – like log an error and retry, or trying to things up before crashing the program. It’s not guaranteed to work, but often you get lucky and it works Well Enough™. Or maybe you just have some code and you want to make some predictions about what could happen if it saw an unexpected new error.
Currently, and with PEP 654’s except
semantics, you can’t predict exactly what will happen with an unexpected exception, because of the whole “system is in an unknown state thing”. But you at least know that there are only two possibilities: try
will either execute an except
block or not, and you can make some approximate guess about what happens in each case. But, if except
starts automatically looping on ExceptionGroup
s, then you also need to consider the looping case. So it’s increasing the number of possibilities that experts need to keep in mind, and it creates potential control-flow paths that are impossible for beginners to anticipate if they don’t even know about ExceptionGroup
s.
I think this might be the most important issue with automatic looping in except
– the one that’s really making everyone (including me) nervous. Yury’s except mydb.ConnectionError: await sleep(1)
and except ResourceError: resource.close()
snippets are both examples of this issue.
The weird thing is, on the practical-to-mathematical spectrum of programmers, my natural disposition puts me way over on the mathematical side – I’m the kind of person who wants to meticulously enumerate all possible states a program could possibly get into and handle them all correctly and verify that to the maximum extent possible, and I have to fight against that urge to get stuff done. So you’d expect me to be super creeped out by the idea of these new, unaudited control flow paths through programs – and in fact, I was. Like everyone else here, I started out with the intuition that automatic looping in except
was obviously unacceptable.
So what changed? Why am I even raising the possibility?
Well… I got to thinking. The vast majority of non-trivial Python programs contain some short windows where a KeyboardInterrupt
will irrecoverably corrupt their internal state. And it’s… basically fine. The interpreter itself has had bugs like this that persisted for years and the world didn’t end. Theoretically, the chance of things going wrong is definitely larger than zero, but numerically, it’s rare enough that almost no-one cares, or even notices. It disappears into the background noise of programs doing weird stuff.
And it’s not just KeyboardInterrupt
– it’s a particularly blatant example, but in some sense, the whole point of Python choosing an exception-based model for error handling instead of, say, Rust’s type-checked error types or Java’s checked exceptions, is to let Python users lean into the philosophy of “well we might end up in some undefined state but whatever, let’s YOLO ahead anyway and deal with it later if it’s a problem”. Practically every bytecode in Python is allowed to bail out with a variety of different exceptions, and even mypy doesn’t offer any way to track which exceptions are possible and make sure you’re handling them all. Actual existing Python programs never properly handle all possible exceptions. And it’s fine.
Of course, this isn’t an accident – it’s critical that Python does something reasonable with unhandled exceptions (propagate, crash the program, print diagnostics, etc.). It might not be the right thing for any particular case, but it’s close-enough to right, often enough, that it’s fine.
So with that in mind – for the interaction between except
+ EG to cause a problem, you need a case where a bunch of things line up:
- you’re calling some code where you don’t understand the error-reporting API, so you can’t make any guarantees, just do “best effort” defensive programming.
- The specific way you don’t understand the error-handling is that the API can raise EGs and you didn’t know about it.
- you hit a situation where an EG is actually raised, which may require losing a race condition
- you have a
try
block to handle exceptions from this API, but it’s not an except:
or except BaseException:
or except Exception:
or finally
, because those all retain at-most-once semantics in every EG proposal
- the default
except
semantics turn out to be the wrong thing your situation
Note that everything above applies equally to PEP 654 and the flat EGs/automatic looping approach. The only difference is that they have different fallback semantics if you finally reach the bottom of that list: for PEP 654 you get an unhandled exception escaping from the except
block that you might expect to catch it (err on the side of potentially running except
too few times), while with automatic looping we err on the side of potentially running except
too many times.
Obviously you can construct examples where either of these fallbacks are arbitrarily broken. But is one of them broken more often? None of us have collected data on this, but it’s at least plausible that automatic looping is right more often than letting unhandled exceptions escape. Do these situations even happen often enough to materially affect the bug rate of Python programs? Also unclear – again, all Python programs have bugs with unexpected exceptions. (How many of your programs handle ENOSPACE correctly?)
So… I’m not saying this argument makes a slam-dunk case for automatic looping in except
being perfect and wonderful. It doesn’t. But I do think it makes the case that instead of automatically rejecting it out-of-hand, we should try to gather more data (e.g. grep through some projects and see how many try
blocks are broken under each design), and consider whether the other advantages might be enough to outweigh the problems.