Skip to content

← Error Handling with Exceptions, Retries and Warnings step 12 of 20

Hard Primitives

Split an Exception Group Without Losing Its Shape

An import job collects its errors in a tree of exception groups, with one inner group per file. You want to retry some errors, report the rest, and still know which file each error came from. split() does this. It cuts a group in two and keeps the nesting on both sides. Collect Every Failure in a Batch handled a group with except*, and this exercise takes one apart as data.

Build the tree

build(spec) turns a nested dictionary into exceptions:

  • A spec whose "items" is a list is a group. Build each child that is a mapping, recursively, skip the other entries, and return BaseExceptionGroup(message, children). A group ignores any "exc" key.
  • Any other spec is a single error. Its "exc" names a class in LEAF_TYPES. Return that class called with the message.
  • The message is "message" if it is a string, otherwise "".
  • If "note" is a string, even an empty one, attach it with add_note(). Ignore a missing or non-string note.
  • If a single error's "exc" is missing or is not a string, raise ValueError("leaf spec needs an 'exc' name"). An unknown name raises KeyError.

The constructor raises ValueError for a group with no children left to build. It returns an ExceptionGroup when every child is an Exception, and a BaseExceptionGroup otherwise.

Cancelled in the starter stands for a cancellation signal, a request to stop work early. Python makes its own cancellation exception a BaseException on purpose, so that except Exception does not catch it. Cancelled works the same way.

Partition the errors

partition_errors(eg, *, retryable) returns a (retryable, terminal) pair:

  1. Split eg on Exception. If the other half exists, for example a Cancelled or a KeyboardInterrupt, raise that half at once. The caller then sees only that half, and the ordinary errors go unreported.
  2. Otherwise split the Exception half on the retryable tuple of classes, and return the pair that split() gives you.

Either half can be None, and an empty retryable tuple matches nothing. split() keeps the original errors with their nesting, order, messages and notes, so build no new errors.

Describe the result

describe(exc) turns an exception into plain data, recursively:

group: {"kind": "group", "type": <class name>, "message": <group message>,
        "notes": [...], "items": [<described children>]}
leaf:  {"kind": "leaf", "type": <class name>, "message": str(exc), "notes": [...]}

str() of a group adds a count, as in batch (2 sub-exceptions), so read the group's message attribute instead. "notes" lists the exception's own notes, or []. A group's notes stay on the group and do not repeat on its children.

solve(tree, retryable) builds the root, looks up each retryable name in RETRYABLE_TYPES, partitions the root, and describes the result:

{"outcome": "partitioned", "retryable": <description or None>, "terminal": <description or None>}
{"outcome": "escaped", "escaped": <description>}
  • If the root is not a group, raise TypeError("the top-level spec must be a group").
  • Let errors from build and unknown retryable names escape.
  • Catch the group that partition_errors raises with a plain except BaseExceptionGroup, and return the escaped shape.

RETRYABLE_TYPES includes TerminalError on purpose. The caller's list decides what is retryable, not the class name. A name listed twice still matches each error once.

In the starter's types, BaseExceptionGroup[Exception] is a group of Exception instances, and type[Exception] means a class such as ValueError itself. Cancelled is not an Exception, so mypy rejects it in RETRYABLE_TYPES.