Skip to content

Commit acacf08

Browse files
Zac-HDhugovkJelleZijlstra
authored
PEP 785: New methods for easier handling of ExceptionGroups (#4357)
* PEP-785: initial draft * PEP 785: update title formatting Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> * Update peps/pep-0785.rst * PEP-785: fix links and formatting --------- Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
1 parent 7a093da commit acacf08

File tree

2 files changed

+381
-0
lines changed

2 files changed

+381
-0
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@ peps/pep-0781.rst @methane
663663
peps/pep-0782.rst @vstinner
664664
peps/pep-0783.rst @hoodmane @ambv
665665
peps/pep-0784.rst @gpshead
666+
peps/pep-0785.rst @gpshead
666667
# ...
667668
peps/pep-0789.rst @njsmith
668669
# ...

peps/pep-0785.rst

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
PEP: 785
2+
Title: New methods for easier handling of ``ExceptionGroup``\ s
3+
Author: Zac Hatfield-Dodds <zac@zhd.dev>
4+
Sponsor: Gregory P. Smith <greg@krypto.org>
5+
Status: Draft
6+
Type: Standards Track
7+
Created: 08-Apr-2025
8+
Python-Version: 3.14
9+
10+
Abstract
11+
========
12+
13+
As :pep:`654` :class:`ExceptionGroup` has come into widespread use across the
14+
Python community, some common but awkward patterns have emerged. We therefore
15+
propose adding two new methods to exception objects:
16+
17+
- :meth:`!BaseExceptionGroup.flat_exceptions`, returning the 'leaf' exceptions as
18+
a list, with each traceback composited from any intermediate groups.
19+
20+
- :meth:`!BaseException.preserve_context`, a context manager which
21+
saves and restores the :attr:`!self.__context__` attribute of ``self``,
22+
so that re-raising the exception within another handler does not overwrite
23+
the existing context.
24+
25+
We expect this to enable more concise expression of error handling logic in
26+
many medium-complexity cases. Without them, exception-group handlers will
27+
continue to discard intermediate tracebacks and mis-handle ``__context__``
28+
exceptions, to the detriment of anyone debugging async code.
29+
30+
31+
Motivation
32+
==========
33+
34+
As exception groups come into widespread use, library authors and end users
35+
often write code to process or respond to individual leaf exceptions, for
36+
example when implementing middleware, error logging, or response handlers in
37+
a web framework.
38+
39+
`Searching GitHub`__ found four implementations of :meth:`!flat_exceptions` by
40+
various names in the first sixty hits, of which none handle
41+
tracebacks.\ [#numbers]_ The same search found thirteen cases where
42+
:meth:`!.flat_exceptions` could be used. We therefore believe that providing
43+
a method on the :class:`BaseException` type with proper traceback preservation
44+
will improve error-handling and debugging experiences across the ecosystem.
45+
46+
__ https://github.com/search?q=%2Ffor+%5Cw%2B+in+%5Beg%5D%5Cw*%5C.exceptions%3A%2F+language%3APython&type=code
47+
48+
The rise of exception groups has also made re-raising exceptions caught by an
49+
earlier handler much more common: for example, web-server middleware might
50+
unwrap ``HTTPException`` if that is the sole leaf of a group:
51+
52+
.. code-block:: python
53+
54+
except* HTTPException as group:
55+
first, *rest = group.flat_exceptions() # get the whole traceback :-)
56+
if not rest:
57+
raise first
58+
raise
59+
60+
However, this innocent-seeming code has a problem: ``raise first`` will do
61+
``first.__context__ = group`` as a side effect. This discards the original
62+
context of the error, which may contain crucial information to understand why
63+
the exception was raised. In many production apps it also causes tracebacks
64+
to balloon from hundreds of lines, to tens or even `hundreds of thousands of
65+
lines`__ - a volume which makes understanding errors far more difficult than
66+
it should be.
67+
68+
__ https://github.com/python-trio/trio/issues/2001#issuecomment-931928509
69+
70+
71+
A new :meth:`!BaseException.preserve_context` method would be a discoverable,
72+
readable, and easy-to-use solution for these cases.
73+
74+
75+
Specification
76+
=============
77+
78+
A new method ``flat_exceptions()`` will be added to ``BaseExceptionGroup``, with the
79+
following signature:
80+
81+
.. code-block:: python
82+
83+
def flat_exceptions(self, *, fix_tracebacks=True) -> list[BaseException]:
84+
"""
85+
Return a flat list of all 'leaf' exceptions in the group.
86+
87+
If fix_tracebacks is True, each leaf will have the traceback replaced
88+
with a composite so that frames attached to intermediate groups are
89+
still visible when debugging. Pass fix_tracebacks=False to disable
90+
this modification, e.g. if you expect to raise the group unchanged.
91+
"""
92+
93+
A new method ``preserve_context()`` will be added to ``BaseException``, with the
94+
following signature:
95+
96+
.. code-block:: python
97+
98+
def preserve_context(self) -> contextlib.AbstractContextManager[Self]:
99+
"""
100+
Context manager that preserves the exception's __context__ attribute.
101+
102+
When entering the context, the current values of __context__ is saved.
103+
When exiting, the saved value is restored, which allows raising an
104+
exception inside an except block without changing its context chain.
105+
"""
106+
107+
Usage example:
108+
109+
.. code-block:: python
110+
111+
# We're an async web framework, where user code can raise an HTTPException
112+
# to return a particular HTTP error code to the client. However, it may
113+
# (or may not) be raised inside a TaskGroup, so we need to use `except*`;
114+
# and if there are *multiple* such exceptions we'll treat that as a bug.
115+
try:
116+
user_code_here()
117+
except* HTTPException as group:
118+
first, *rest = group.flat_exceptions()
119+
if rest:
120+
raise # handled by internal-server-error middleware
121+
... # logging, cache updates, etc.
122+
with first.preserve_context():
123+
raise first
124+
125+
Without ``.preserve_context()``, this code would have to either:
126+
127+
* arrange for the exception to be raised *after* the ``except*`` block,
128+
making code difficult to follow in nontrivial cases, or
129+
* discard the existing ``__context__`` of the ``first`` exception, replacing
130+
it with an ``ExceptionGroup`` which is simply an implementation detail, or
131+
* use ``try/except`` instead of ``except*``, handling the possibility that the
132+
group doesn't contain an ``HTTPException`` at all,\ [#catch-raw-group]_ or
133+
* implement the semantics of ``.preserve_context()`` inline; while this is not
134+
*literally unheard-of*, it remains very rare.
135+
136+
137+
Backwards Compatibility
138+
=======================
139+
140+
Adding new methods to built-in classes, especially those as widely used as
141+
``BaseException``, can have substantial impacts. However, GitHub search shows
142+
no collisions for these method names (`zero hits`__ and
143+
`three unrelated hits`__ respectively). If user-defined methods with these
144+
names exist in private code they will shadow those proposed in the PEP,
145+
without changing runtime behavior.
146+
147+
__ https://github.com/search?q=%2F%5C.flat_exceptions%5C%28%2F+language%3APython&type=code
148+
__ https://github.com/search?q=%2F%5C.preserve_context%5C%28%2F+language%3APython&type=code
149+
150+
151+
How to Teach This
152+
=================
153+
154+
Working with exception groups is an intermediate-to-advanced topic, unlikely
155+
to arise for beginning programmers. We therefore suggest teaching this topic
156+
via documentation, and via just-in-time feedback from static analysis tools.
157+
In intermediate classes, we recommend teaching ``.flat_exceptions()`` together
158+
with the ``.split()`` and ``.subgroup()`` methods, and mentioning
159+
``.preserve_context()`` as an advanced option to address specific pain points.
160+
161+
Both the API reference and the existing `ExceptionGroup tutorial`__
162+
should be updated to demonstrate and explain the new methods. The tutorial
163+
should include examples of common patterns where ``.flat_exceptions()`` and
164+
``.preserve_context()`` help simplify error handling logic. Downstream
165+
libraries which often use exception groups could include similar docs.
166+
167+
__ https://docs.python.org/3/tutorial/errors.html#raising-and-handling-multiple-unrelated-exceptions
168+
169+
We have also designed lint rules for inclusion in ``flake8-async`` which will
170+
suggest using ``.flat_exceptions()`` when iterating over ``group.exceptions``
171+
or re-raising a leaf exception, and suggest using ``.preserve_context()`` when
172+
re-raising a leaf exception inside an ``except*`` block would override any
173+
existing context.
174+
175+
176+
Reference Implementation
177+
========================
178+
179+
While the methods on built-in exceptions will be implemented in C if this PEP
180+
is accepted, we hope that the following Python implementation will be useful
181+
on older versions of Python, and can demonstrate the intended semantics.
182+
183+
We have found these helper functions quite useful when working with
184+
:class:`ExceptionGroup`\ s in a large production codebase.
185+
186+
A ``flat_exceptions()`` helper function
187+
---------------------------------------
188+
189+
.. code-block:: python
190+
191+
import copy
192+
import types
193+
from types import TracebackType
194+
195+
196+
def flat_exceptions(
197+
self: BaseExceptionGroup, *, fix_traceback: bool = True
198+
) -> list[BaseException]:
199+
"""
200+
Return a flat list of all 'leaf' exceptions.
201+
202+
If fix_tracebacks is True, each leaf will have the traceback replaced
203+
with a composite so that frames attached to intermediate groups are
204+
still visible when debugging. Pass fix_tracebacks=False to disable
205+
this modification, e.g. if you expect to raise the group unchanged.
206+
"""
207+
208+
def _flatten(group: BaseExceptionGroup, parent_tb: TracebackType | None = None):
209+
group_tb = group.__traceback__
210+
combined_tb = _combine_tracebacks(parent_tb, group_tb)
211+
result = []
212+
for exc in group.exceptions:
213+
if isinstance(exc, BaseExceptionGroup):
214+
result.extend(_flatten(exc, combined_tb))
215+
elif fix_tracebacks:
216+
tb = _combine_tracebacks(combined_tb, exc.__traceback__)
217+
result.append(exc.with_traceback(tb))
218+
else:
219+
result.append(exc)
220+
return result
221+
222+
return _flatten(self)
223+
224+
225+
def _combine_tracebacks(
226+
tb1: TracebackType | None,
227+
tb2: TracebackType | None,
228+
) -> TracebackType | None:
229+
"""
230+
Combine two tracebacks, putting tb1 frames before tb2 frames.
231+
232+
If either is None, return the other.
233+
"""
234+
if tb1 is None:
235+
return tb2
236+
if tb2 is None:
237+
return tb1
238+
239+
# Convert tb1 to a list of frames
240+
frames = []
241+
current = tb1
242+
while current is not None:
243+
frames.append((current.tb_frame, current.tb_lasti, current.tb_lineno))
244+
current = current.tb_next
245+
246+
# Create a new traceback starting with tb2
247+
new_tb = tb2
248+
249+
# Add frames from tb1 to the beginning (in reverse order)
250+
for frame, lasti, lineno in reversed(frames):
251+
new_tb = types.TracebackType(
252+
tb_next=new_tb, tb_frame=frame, tb_lasti=lasti, tb_lineno=lineno
253+
)
254+
255+
return new_tb
256+
257+
258+
A ``preserve_context()`` context manager
259+
----------------------------------------
260+
261+
.. code-block:: python
262+
263+
class preserve_context:
264+
def __init__(self, exc: BaseException):
265+
self.__exc = exc
266+
self.__context = exc.__context__
267+
268+
def __enter__(self):
269+
return self.__exc
270+
271+
def __exit__(self, exc_type, exc_value, traceback):
272+
assert exc_value is self.__exc, f"did not raise the expected exception {self.__exc!r}"
273+
exc_value.__context__ = self.__context
274+
del self.__context # break gc cycle
275+
276+
277+
Rejected Ideas
278+
==============
279+
280+
Add utility functions instead of methods
281+
----------------------------------------
282+
283+
Rather than adding methods to exceptions, we could provide utility functions
284+
like the reference implementations above.
285+
There are however several reasons to prefer methods: there's no obvious place
286+
where helper functions should live, they take exactly one argument which must
287+
be an instance of ``BaseException``, and methods are both more convenient and
288+
more discoverable.
289+
290+
291+
Add ``BaseException.as_group()`` (or group methods)
292+
---------------------------------------------------
293+
294+
Our survey of ``ExceptionGroup``-related error handling code also observed
295+
many cases of duplicated logic to handle both a bare exception, and the same
296+
kind of exception inside a group (often incorrectly, motivating
297+
``.flat_exceptions()``).
298+
299+
We briefly `proposed <https://github.com/python/cpython/issues/125825>`__
300+
adding ``.split(...)`` and ``.subgroup(...)`` methods too all exceptions,
301+
before considering ``.flat_exceptions()`` made us feel this was too clumsy.
302+
As a cleaner alternative, we sketched out an ``.as_group()`` method:
303+
304+
.. code-block:: python
305+
306+
def as_group(self):
307+
if not isinstance(self, BaseExceptionGroup):
308+
return BaseExceptionGroup("", [self])
309+
return self
310+
311+
However, applying this method to refactor existing code was a negligible
312+
improvement over writing the trivial inline version. We also hope that many
313+
current uses for such a method will be addressed by ``except*`` as older
314+
Python versions reach end-of-life.
315+
316+
We recommend documenting a "convert to group" recipe for de-duplicated error
317+
handling, instead of adding group-related methods to ``BaseException``.
318+
319+
320+
Add ``e.raise_with_preserved_context()`` instead of a context manager
321+
---------------------------------------------------------------------
322+
323+
We prefer the context-manager form because it allows ``raise ... from ...``
324+
if the user wishes to (re)set the ``__cause__``, and is overall somewhat
325+
less magical and tempting to use in cases where it would not be appropriate.
326+
We could be argued around though, if others prefer this form.
327+
328+
329+
Footnotes
330+
=========
331+
332+
.. [#numbers]
333+
From the first sixty `GitHub search results
334+
<https://github.com/search?q=%2Ffor+%5Cw%2B+in+%5Beg%5D%5Cw*%5C.exceptions%3A%2F+language%3APython&type=code>`__
335+
for ``for \w+ in [eg]\w*\.exceptions:``, we find:
336+
337+
* Four functions implementing ``flat_exceptions()`` semantics, none of
338+
which preserve tracebacks:
339+
(`one <https://github.com/nonebot/nonebot2/blob/570bd9587af99dd01a7d5421d3105d8a8e2aba32/nonebot/utils.py#L259-L266>`__,
340+
`two <https://github.com/HypothesisWorks/hypothesis/blob/7c49f2daf602bc4e51161b6c0bc21720d64de9eb/hypothesis-python/src/hypothesis/core.py#L763-L770>`__,
341+
`three <https://github.com/BCG-X-Official/pytools/blob/9d6d37280b72724bd64f69fe7c98d687cbfa5317/src/pytools/asyncio/_asyncio.py#L269-L280>`__,
342+
`four <https://github.com/M-o-a-T/moat/blob/ae174b0947288364f3ae580cb05522624f4f6f39/moat/util/exc.py#L10-L18>`__)
343+
344+
* Six handlers which raise the first exception in a group, discarding
345+
any subsequent errors; these would benefit from both proposed methods.
346+
(`one <https://github.com/Lancetnik/FastDepends/blob/239cd1a58028782a676934f7d420fbecf5cb6851/fast_depends/core/model.py#L488-L490>`__,
347+
`two <https://github.com/estuary/connectors/blob/677824209290c0a107e63d5e2fccda7c8388101e/source-hubspot-native/source_hubspot_native/buffer_ordered.py#L108-L111>`__,
348+
`three <https://github.com/MobileTeleSystems/data-rentgen/blob/7525f7ecafe5994a6eb712d9e66b8612f31436ef/data_rentgen/consumer/__init__.py#L65-L67>`__,
349+
`four <https://github.com/ljmf00/simbabuild/blob/ac7e0999563b3a1b13f4e445a99285ea71d4c7ab/simbabuild/builder_async.py#L22-L24>`__,
350+
`five <https://github.com/maxjo020418/BAScraper/blob/cd5c2ef24f45f66e7f0fb26570c2c1529706a93f/BAScraper/BAScraper_async.py#L170-L174>`__,
351+
`six <https://github.com/sobolevn/faststream/blob/0d6c9ee6b7703efab04387c51c72876e25ad91a7/faststream/app.py#L54-L56>`__)
352+
353+
* Seven cases which mishandle nested exception groups, and would thus
354+
benefit from ``flat_exceptions()``. We were surprised to note that only
355+
one of these cases could straightforwardly be replaced by use of an
356+
``except*`` clause or ``.subgroup()`` method.
357+
(`one <https://github.com/vertexproject/synapse/blob/ed8148abb857d4445d727768d4c57f4f11b0d20a/synapse/lib/stormlib/iters.py#L82-L88>`__,
358+
`two <https://github.com/mhdzumair/MediaFusion/blob/ff906378f32fb8419ef06c6f1610c08946dfaeee/scrapers/base_scraper.py#L375-L386>`__,
359+
`three <https://github.com/SonySemiconductorSolutions/local-console/blob/51f5af806336e169d3dd9b9f8094a29618189f5e/local-console/src/local_console/commands/server.py#L61-L67>`__,
360+
`four <https://github.com/SonySemiconductorSolutions/local-console/blob/51f5af806336e169d3dd9b9f8094a29618189f5e/local-console/src/local_console/commands/broker.py#L66-L69>`__,
361+
`five <https://github.com/HexHive/Tango/blob/5c8472d1679068daf0f041dbbda21e05281b10a3/tango/fuzzer.py#L143-L160>`__,
362+
`six <https://github.com/PaLora16/ExceptionsGroupsValidators/blob/41152a86eec695168fdec74653694658ddc788fc/main.py#L39-L44>`__,
363+
`seven <https://github.com/reactive-python/reactpy/blob/178fc05de7756f7402ed2ee1e990af0bdad42d9e/src/reactpy/backend/starlette.py#L164-L170>`__)
364+
365+
indicating that more than a quarter of *all* hits for this fairly general
366+
search would benefit from the methods proposed in this PEP.
367+
368+
.. [#catch-raw-group]
369+
This remains very rare, and most cases duplicate logic across
370+
``except FooError:`` and ``except ExceptionGroup: # containing FooError``
371+
clauses rather than using something like the ``as_group()`` trick.
372+
We expect that ``except*`` will be widely used in such cases by the time
373+
that the methods proposed by this PEP are widely available.
374+
375+
376+
Copyright
377+
=========
378+
379+
This document is placed in the public domain or under the CC0-1.0-Universal license,
380+
whichever is more permissive.

0 commit comments

Comments
 (0)