Exception Groups, except* & Structured Concurrency

When running concurrent tasks (via asyncio or worker pools), multiple operations can fail simultaneously. Prior to Python 3.11, reporting multiple concurrent errors required swallowing or picking an arbitrary single exception to raise. Introduced in PEP 654 and PEP 654/655, Exception Groups (ExceptionGroup), except* filtering syntax, and asyncio.TaskGroup provide structured concurrency and multi-exception handling.

This chapter details ExceptionGroup architecture, except* partial exception matching syntax, BaseExceptionGroup, and asyncio.TaskGroup structured concurrency.


1. Exception Groups Architecture (ExceptionGroup / PEP 654)

An ExceptionGroup is an exception containing a tree of nested exception instances:

ExceptionGroup Structural Tree:

[ ExceptionGroup: "Multiple task failures" ]
β”œβ”€β”€ [ ValueError: "Invalid user ID" ]
└── [ ExceptionGroup: "Sub-task failures" ]
    β”œβ”€β”€ [ ConnectionError: "Database timeout" ]
    └── [ KeyError: "Missing auth token" ]
# Instantiating an ExceptionGroup manually
eg = ExceptionGroup(
    "Batch processing failed",
    [ValueError("Invalid ID"), ConnectionError("DB Timeout")]
)
raise eg

2. Partial Exception Filtering via except* Syntax

Standard except ValueError: can only catch a single top-level exception. Handling an ExceptionGroup containing heterogeneous exception types requires except* syntax:

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch_user())  # Raises ValueError
        tg.create_task(fetch_db())    # Raises ConnectionError
except* ValueError as eg:
    # Catches ONLY the ValueError instances from the ExceptionGroup!
    print(f"Handled ValueErrors: {eg.exceptions}")
except* ConnectionError as eg:
    # Catches ONLY the ConnectionError instances from the ExceptionGroup!
    print(f"Handled ConnectionErrors: {eg.exceptions}")

except* Matching Invariants:

  1. Partial Splitting: except* T splits the matching exception instances of type T into a new sub-ExceptionGroup, leaving un-matched exceptions in the original group.
  2. Multiple Branch Execution: Multiple except* blocks can execute for a single try block if the ExceptionGroup contains types matching multiple branches!

3. Structured Concurrency with asyncio.TaskGroup

Introduced in Python 3.11, asyncio.TaskGroup is an asynchronous context manager that guarantees Structured Concurrency:

import asyncio

async def main():
    try:
        async with asyncio.TaskGroup() as tg:
            # Spawn concurrent tasks
            task1 = tg.create_task(fetch_api_a())
            task2 = tg.create_task(fetch_api_b())
    except* TimeoutError:
        print("One or more API tasks timed out!")

Structured Concurrency Invariants:

  • Guaranteed Exit Joining: When exiting the async with TaskGroup() block, execution pauses until all child tasks have completed.
  • Automatic Cancellation: If any task raises an unhandled exception, TaskGroup automatically cancels all remaining active child tasks.
  • Combined Exception Reporting: All task exceptions are aggregated into an ExceptionGroup and raised together.

4. Production Trade-offs: TaskGroup vs gather()

  • asyncio.gather(*tasks): Legacy task aggregator. If one task fails, remaining tasks continue running in the background untracked (leaking dangling tasks) unless explicitly cancelled.
  • asyncio.TaskGroup(): Modern structured concurrency. Eliminates dangling background tasks by enforcing deterministic cleanup and cancellation.
Display Options
Appearance
Text Size
100%