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