ExceptionGroup y except*: múltiples excepciones simultáneas

Cuando una sola operación puede fallar con una sola excepción, el modelo try/except clásico funciona perfectamente. Pero cuando lanzas diez tareas concurrentes y tres de ellas fallan por razones distintas, ¿cuál de las tres capturas? Con el modelo anterior, la respuesta era decepcionante: solo la primera que el runtime decidiera propagar, descartando silenciosamente el resto.

ExceptionGroup es el tipo que Python 3.11 introdujo para resolver exactamente este problema: un contenedor que agrupa múltiples excepciones que ocurrieron simultáneamente, preservando todas. No es un wrapper genérico de errores ni un sustituto de Exception —es específico para el caso en que la semántica correcta es “fallaron varias cosas a la vez y todas importan”.

El diseño surge directamente de la naturaleza de asyncio.TaskGroup: cuando un grupo de tareas corre en paralelo, no tiene sentido cancelar las demás en cuanto la primera falla y luego ignorar sus resultados. Todas las tareas que terminen con error contribuyen al ExceptionGroup que se propaga al llamador. Así el programador obtiene información completa, no parcial.

Para consumir un ExceptionGroup existe la nueva cláusula except* (con asterisco). La semántica es deliberadamente diferente a except: no captura la excepción completa, sino el subconjunto de excepciones dentro del grupo que coinciden con el tipo indicado. Las demás siguen propagándose. Varios except* en el mismo try pueden coexistir y cada uno recibe su slice del grupo —el runtime hace la partición internamente.

Lo que rompe si no entiendes esto: mezclar except y except* en el mismo try es un SyntaxError. Intentar capturar un ExceptionGroup con except Exception funciona sintácticamente, pero te da el grupo entero como un único objeto opaco y pierdes acceso estructurado a las excepciones individuales. Y si usas except* con un tipo que no existe en el grupo, la cláusula simplemente no ejecuta —no lanza error, lo cual puede generar comportamiento silenciosamente incorrecto si confías en que algo se ejecutó.

import asyncio
import httpx  # pip install httpx


# Simulamos tres endpoints: uno lento, uno con error HTTP, uno con fallo de red
async def fetch(client: httpx.AsyncClient, url: str) -> str:
    response = await client.get(url, timeout=3.0)
    response.raise_for_status()  # lanza httpx.HTTPStatusError si 4xx/5xx
    return response.text


async def fetch_all(urls: list[str]) -> list[str]:
    async with httpx.AsyncClient() as client:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(fetch(client, url)) for url in urls]
    # Si llegamos aquí, todas tuvieron éxito
    return [t.result() for t in tasks]


async def main() -> None:
    urls = [
        "https://httpbin.org/status/200",
        "https://httpbin.org/status/503",   # lanzará HTTPStatusError
        "https://httpbin.org/status/404",   # lanzará HTTPStatusError
        "https://this-domain-does-not-exist.invalid/",  # ConnectError
    ]

    try:
        results = await fetch_all(urls)
    except* httpx.HTTPStatusError as eg:
        # eg.exceptions contiene solo las HTTPStatusError del grupo
        for exc in eg.exceptions:
            print(f"[HTTP error] {exc.response.status_code} — {exc.request.url}")
    except* httpx.ConnectError as eg:
        for exc in eg.exceptions:
            print(f"[Connect error] No se pudo conectar: {exc.request.url}")
    # Si existieran otros tipos de excepción en el grupo, se propagarían


asyncio.run(main())

Qué está pasando y por qué cada decisión importa

asyncio.TaskGroup espera a que todas las tareas terminen antes de propagar errores —incluyendo las que ya fallaron mientras las demás seguían corriendo. Cuando el bloque async with sale con errores, construye un ExceptionGroup con todas las excepciones recolectadas. Por eso fetch_all no necesita lógica de recolección manual: el propio TaskGroup hace ese trabajo.

La variable eg en except* httpx.HTTPStatusError as eg es un nuevo ExceptionGroup —no la excepción individual— que contiene solo las excepciones del tipo indicado. Por eso iteramos eg.exceptions. La firma de ExceptionGroup incluye un mensaje y una lista de BaseException, y except* construye un subgrupo con el mismo mensaje y las excepciones filtradas.

Fíjate en que los dos except* son independientes: el runtime particiona el ExceptionGroup original entre todos los except* del bloque. Si una excepción coincide con el primero, no se ofrece al segundo. Si queda algún tipo sin cubrir, Python re-lanza un ExceptionGroup residual con las excepciones no capturadas —este comportamiento es explícito en el PEP 654 y garantiza que ningún error se descarte silenciosamente.

Un detalle que suele sorprender: ExceptionGroup hereda de Exception, pero BaseExceptionGroup hereda de BaseException. Si tus tareas pueden lanzar KeyboardInterrupt o SystemExit (que heredan de BaseException), el runtime usa BaseExceptionGroup automáticamente, lo que te impide capturarlo con except* Exception —tienes que ser explícito con except* KeyboardInterrupt.

Errores que debes conocer

Error: mezclar except y except* en el mismo bloque try.

# ❌ Wrong
try:
    await fetch_all(urls)
except* httpx.HTTPStatusError as eg:
    ...
except Exception as e:   # SyntaxError: no puedes combinar except y except*
    ...

# ✅ Right
try:
    await fetch_all(urls)
except* httpx.HTTPStatusError as eg:
    ...
except* httpx.ConnectError as eg:
    ...

Son semánticas incompatibles: except captura una excepción singular, except* particiona un grupo. Python lo detecta en parse time.


Error: lanzar ExceptionGroup cuando la semántica correcta es una sola excepción —o asumir que raise ExceptionGroup(...) con un único elemento se desenvuelve automáticamente.

# ❌ Wrong — confunde al caller, que ahora necesita except* para un solo error
def validate(value: int) -> None:
    if value < 0:
        raise ExceptionGroup("validation", [ValueError("debe ser positivo")])

# ✅ Right
def validate(value: int) -> None:
    if value < 0:
        raise ValueError("debe ser positivo")

ExceptionGroup tiene sentido cuando múltiples excepciones ocurren concurrentemente. Usarlo para una sola excepción sincrónica rompe las convenciones del caller y no aporta nada.


Error: capturar ExceptionGroup con except Exception en lugar de except*, procesando el grupo como si fuera un error simple.

# ❌ Wrong — eg es el ExceptionGroup completo, no las excepciones individuales
try:
    await fetch_all(urls)
except Exception as eg:
    print(eg)  # imprime el grupo, pierdes acceso tipado a cada fallo

# ✅ Right
try:
    await fetch_all(urls)
except* httpx.HTTPStatusError as eg:
    for exc in eg.exceptions:
        print(exc.response.status_code)

Con except Exception obtienes el objeto ExceptionGroup como cualquier excepción —sin partición por tipo y sin re-propagación automática del residuo— lo que te obliga a introspectarlo manualmente y te expone a perder errores no contemplados.

80

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio