Skip to content

← Failure by Design step 9 of 18

Medium End-to-End

Failure aggregation over batches

A validator that reports every field failure at once is the clearest non-async motivation for exception groups. Nobody enjoys the API that rejects a twelve-field form one field per round trip — and nobody enjoys the batch job that dies on record 3 of 40 000 and tells you nothing about records 4 through 40 000.

The shape you want: attempt every record, collect the failures, and decide at the end whether the batch as a whole failed. Along the way, tag each collected exception with the record that produced it — because by the time that exception surfaces three layers up, the loop variable that knew the id is long gone. add_note() is the tool for exactly that, and it travels with the exception through the group and out to the traceback.

What to build

def process_batch(records: Sequence[Mapping[str, str]], handler: Handler) -> BatchReport:

For each record, in order:

  • call handler(record);
  • on Exception, call exc.add_note(f"record={record_id}"), collect it, and record the id as failed;
  • on success, record the id as succeeded;
  • a BaseException that is not an Exception must propagate immediately and must not be collected. The handler that raises KeyboardInterrupt is hostile on purpose: a batch runner that keeps grinding through 40 000 records after the operator hit Ctrl-C is the failure mode this rule exists to prevent.

Afterwards: if there were failures and nothing succeeded, raise ExceptionGroup("every record in the batch failed", errors). Otherwise return BatchReport(succeeded, failed, errors) with the three tuples in encounter order.

Watch the empty batch. all([]) is True, so the naive spelling of “every record failed” raises on a batch of zero records — and ExceptionGroup with an empty member list raises ValueError anyway. Phrase the condition so that an empty batch produces an empty report, which is the only sane answer.

The probe

def solve(records: Sequence[Mapping[str, str]]) -> dict[str, object]:

Each record is {"id": <str>, "action": <str>} where the action is one of:

action handler behaviour
"ok" returns
"transient" raises TransientError(f"{record_id} timed out")
"terminal" raises TerminalError(f"{record_id} is malformed")
"abort" raises KeyboardInterrupt(f"{record_id} interrupted the worker")

The handler appends each record id to a shared attempted list before doing anything else.

Now consume the result with except*, which is the point of the exercise:

try:
    try:
        report = process_batch(records, handler)
    except* TransientError as transient_group:
        ...
    except* TerminalError as terminal_group:
        ...
except BaseException as exc:
    ...

Two things that shape this code and are not negotiable. You cannot mix except and except* on the same try, which is why the BaseException boundary is an outer statement. And return is a syntax error inside an except* clause — so each clause assigns to a variable and the returns happen afterwards. Both restrictions come from the same place: an except* block may run more than once conceptually, so control flow out of it is ill-defined.

Return exactly one of three shapes:

{"outcome": "report", "succeeded": <tuple>, "failed": <tuple>, "errors": [...], "attempted": [...]}
{"outcome": "group",  "transient": [...], "terminal": [...], "attempted": [...]}
{"outcome": "aborted", "abort_type": <class name>, "attempted": [...]}

Every exception is rendered as {"type": <class name>, "message": str(exc), "notes": [...]}. For "group", flatten each matched subgroup down to its leaf members — a subgroup is a tree, not a flat list.

succeeded and failed are the BatchReport tuples, returned as tuples. The grader distinguishes a tuple from a list, deliberately: an immutable report is an immutable report.

What the type checker buys you

type Handler = Callable[[Mapping[str, str]], None] (PEP 695, 3.12) names the seam once. except* TransientError as transient_group binds an ExceptionGroup[TransientError] — not a TransientError — so the helper that flattens it must accept a group, and mypy will tell you if you forgot. That is the single most common first-contact mistake with except*.

Loading visualization…