`raise`, re-raise y encadenamiento de excepciones

Cuando lanzas una excepción en Python, no estás solo señalando que algo salió mal: estás construyendo un relato. Ese relato tiene capítulos — la causa original, la excepción que la envolvió, el contexto en el que ocurrió — y Python te da herramientas precisas para controlar qué partes del relato se cuentan y cómo se conectan.

Cómo funciona el mecanismo

raise con un argumento lanza una nueva excepción. Simple. Pero cuando estás dentro de un bloque except, Python ya está manejando una excepción activa, y eso cambia las reglas del juego.

En ese contexto, si haces raise sin argumentos, re-lanzas la excepción actual sin modificar nada — ni el tipo, ni el mensaje, ni el traceback. Útil cuando solo querías inspeccionar o registrar el error sin absorberlo.

Más interesante es lo que pasa cuando lanzas una excepción nueva desde dentro de un except. Python automáticamente enlaza la excepción nueva con la que estaba activa mediante el atributo __context__. Eso es el encadenamiento implícito: el traceback muestra ambas, con el mensaje During handling of the above exception, another exception occurred. No pediste que pasara; Python lo hace solo.

El encadenamiento explícito con raise Nueva() from original es diferente: establece __cause__ en lugar de __context__, y el traceback dice The above exception was the direct cause of the following exception. Estás declarando intencionalmente la relación causal, no solo informando que coincidieron en el tiempo.

El caso especial es raise Nueva() from None: suprime el contexto por completo. __suppress_context__ se pone a True, y el traceback solo muestra la excepción nueva. Lo usas cuando la causa original es un detalle de implementación que no le incumbe al llamador — por ejemplo, cuando traduces un error de bajo nivel a uno de dominio y exponer la causa confundiría más de lo que aclara.

La decisión de envolver vs re-lanzar tiene una lógica clara: re-lanzas cuando eres un intermediario que no puede manejar el error pero tampoco quieres añadir ruido. Envuelves cuando puedes añadir contexto útil — información que la excepción original no tenía, o cuando quieres traducir excepciones de infraestructura a excepciones de dominio.

import logging
from typing import Any

# ---------------------------------------------------------------------------
# Excepciones de dominio propias
# ---------------------------------------------------------------------------

class ConfigError(Exception):
    """Error al cargar o validar configuración."""

class DatabaseError(Exception):
    """Error en la capa de acceso a datos."""


# ---------------------------------------------------------------------------
# Simulación de capas inferiores con fallos controlados
# ---------------------------------------------------------------------------

def read_config_file(path: str) -> dict[str, Any]:
    """Simula lectura de un archivo de configuración."""
    if path.endswith(".missing"):
        raise FileNotFoundError(f"No se encontró el archivo: {path}")
    if path.endswith(".bad"):
        raise ValueError("El archivo tiene formato inválido")
    return {"host": "localhost", "port": 5432, "db": "myapp"}


def connect_to_db(host: str, port: int) -> str:
    """Simula conexión a una base de datos."""
    if port == 0:
        raise OSError(f"Conexión rechazada en {host}:{port}")
    return f"conn://{host}:{port}"


def fetch_user(conn: str, user_id: int) -> dict[str, Any]:
    """Simula una consulta que puede fallar."""
    if user_id < 0:
        # Re-raise: este nivel no puede manejar el error, solo quiere
        # asegurarse de que queda registrado antes de propagarlo.
        try:
            raise ValueError(f"user_id inválido: {user_id}")
        except ValueError:
            logging.warning("Intento de consulta con ID negativo")
            raise  # preserva traceback original sin modificar nada

    if user_id == 999:
        raise RuntimeError("fallo interno del simulador")

    return {"id": user_id, "name": "Ada"}


# ---------------------------------------------------------------------------
# Capa de servicio: aquí ocurre la lógica de encadenamiento
# ---------------------------------------------------------------------------

def load_config(path: str) -> dict[str, Any]:
    try:
        return read_config_file(path)
    except FileNotFoundError as exc:
        # Encadenamiento explícito: FileNotFoundError ES la causa directa
        # del ConfigError. Queremos que quien llame lo sepa.
        raise ConfigError(f"Configuración no disponible: {path}") from exc
    except ValueError:
        # from None: el ValueError de parseo es un detalle interno.
        # Exponer "formato inválido" junto a "ConfigError" solo confunde.
        raise ConfigError(f"Configuración malformada en: {path}") from None


def get_db_connection(config: dict[str, Any]) -> str:
    try:
        return connect_to_db(config["host"], config["port"])
    except OSError as exc:
        # Encadenamiento explícito: traducimos error de red a error de dominio
        # pero preservamos la causa para facilitar el diagnóstico.
        raise DatabaseError(
            f"No se pudo conectar a {config['host']}:{config['port']}"
        ) from exc


def get_user(config_path: str, user_id: int) -> dict[str, Any]:
    config = load_config(config_path)
    conn = get_db_connection(config)
    return fetch_user(conn, user_id)


# ---------------------------------------------------------------------------
# Punto de entrada: inspeccionamos __cause__ y __context__ manualmente
# ---------------------------------------------------------------------------

def inspect_chain(exc: BaseException, depth: int = 0) -> None:
    indent = "  " * depth
    print(f"{indent}{type(exc).__name__}: {exc}")
    if exc.__cause__ is not None:
        print(f"{indent}  └─ causa explícita (__cause__):")
        inspect_chain(exc.__cause__, depth + 2)
    elif exc.__context__ is not None and not exc.__suppress_context__:
        print(f"{indent}  └─ contexto implícito (__context__):")
        inspect_chain(exc.__context__, depth + 2)


if __name__ == "__main__":
    cases = [
        ("config.missing", 1),   # FileNotFoundError → ConfigError (encadenado)
        ("config.bad", 1),       # ValueError → ConfigError (suprimido)
        ("config.json", -1),     # re-raise puro de ValueError
        ("config.json", 999),    # RuntimeError sin envolver
    ]

    for path, uid in cases:
        print(f"\n{'='*60}")
        print(f"Caso: path={path!r}, user_id={uid}")
        print("=" * 60)
        try:
            result = get_user(path, uid)
            print(f"OK: {result}")
        except Exception as exc:
            inspect_chain(exc)

Qué está pasando en cada decisión

raise ConfigError(...) from exc en load_config declara una relación causal real: el FileNotFoundError es el motivo por el que la configuración no está disponible. Quien capture ConfigError puede acceder a exc.__cause__ si necesita más detalle para logging o diagnóstico. En el traceback de Python, verás ambas excepciones con la cadena de causalidad clara.

raise ConfigError(...) from None para el caso ValueError es una decisión de API. El llamador de load_config no necesita saber que internamente usas un parser que lanza ValueError — eso es un detalle de implementación. Si mañana cambias el parser y lanza ParseError, tu API no cambia. El from None también evita el confuso “During handling of the above exception” que aparecería si no lo suprimieras, porque ValueError sonaría a un error del llamador cuando en realidad es interno.

raise sin argumentos en fetch_user es el patrón correcto para un intermediario. El logging.warning añade contexto operacional sin tocar la excepción. Si hubieras hecho raise exc en lugar de raise, habrías reemplazado el punto de origen del traceback por esta línea — perderías información de dónde ocurrió realmente el error.

inspect_chain muestra cómo consumir programáticamente la cadena. __cause__ existe cuando usaste from algo_concreto. __context__ existe cuando la excepción nueva se lanzó mientras otra estaba activa. __suppress_context__ es el booleano que from None pone a True para que Python no imprima el contexto implícito.

Errores que debes conocer

Error: Re-lanzar con raise exc en vez de raise bare, lo que resetea el traceback al punto del re-raise y pierdes la ubicación original del fallo.

# ❌ Wrong
try:
    risky_operation()
except ValueError as exc:
    log(exc)
    raise exc  # el traceback ahora empieza aquí, no en risky_operation

# ✅ Right
try:
    risky_operation()
except ValueError:
    log_current_exception()
    raise  # preserva el traceback completo desde el origen

Al usar raise sin argumentos, Python reutiliza el objeto de excepción activo tal cual, incluyendo su __traceback__ original.


Error: Usar encadenamiento implícito cuando querías from None, exponiendo detalles internos de implementación que confunden al consumidor de la excepción.

# ❌ Wrong — expone que internamente usas sqlite3
def get_user_by_email(email: str):
    try:
        return db.query("SELECT * FROM users WHERE email=?", email)
    except sqlite3.OperationalError:
        raise UserNotFoundError(f"Usuario no encontrado: {email}")
        # el traceback muestra sqlite3.OperationalError como contexto

# ✅ Right — la causa interna es un detalle de implementación
def get_user_by_email(email: str):
    try:
        return db.query("SELECT * FROM users WHERE email=?", email)
    except sqlite3.OperationalError:
        raise UserNotFoundError(f"Usuario no encontrado: {email}") from None

Con from None, si mañana migras de SQLite a PostgreSQL, la interfaz pública de excepciones no cambia.


Error: Encadenar con from exc cuando exc es la misma excepción que estás transformando, creando una cadena circular que Python detecta pero que resulta en tracebacks incomprensibles.

# ❌ Wrong
try:
    parse(data)
except ValueError as exc:
    raise ValueError("contexto adicional") from exc
    # ahora tienes ValueError causado por ValueError — ruido puro

# ✅ Right — o añades contexto al mensaje, o usas un tipo diferente
try:
    parse(data)
except ValueError as exc:
    raise ParseError(f"contexto adicional: {exc}") from exc
    # tipos distintos → la cadena tiene significado semántico

Si el tipo es el mismo y solo quieres añadir contexto al mensaje, lo más limpio es construir el mensaje combinado y lanzar sin from.

78

Dejar un comentario

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

Scroll al inicio