Atlas

Exception Handling en Producción

Decidir consciente y localmente qué recuperar, qué envolver con contexto de dominio y qué dejar propagar, preservando siempre el root cause (cadena de causas) y sin filtrar detalles internos al cliente.

Teoría

El árbol de excepciones y por qué importa la jerarquía

En Python toda excepción es una instancia de una clase que hereda, en última instancia, de BaseException. La raíz práctica para el código de aplicación es Exception. Conocer el árbol no es trivia: except X captura X y todas sus subclases, así que la posición de una clase en la jerarquía define exactamente qué atrapás.

BaseException
 ├── BaseExceptionGroup            # 3.11+, agrupación de excepciones
 ├── KeyboardInterrupt             # Ctrl-C
 ├── SystemExit                    # sys.exit()
 ├── GeneratorExit                 # cierre de un generador
 └── Exception                     # <- raíz de "errores normales"
      ├── ArithmeticError
      │    └── ZeroDivisionError
      ├── AttributeError
      ├── LookupError
      │    ├── KeyError
      │    └── IndexError
      ├── NameError
      ├── OSError                   # I/O: FileNotFoundError, TimeoutError, ConnectionError...
      ├── RuntimeError
      │    └── RecursionError
      ├── StopIteration
      ├── TypeError
      └── ValueError
           └── UnicodeError

La consecuencia operativa: capturar LookupError atrapa tanto KeyError como IndexError; capturar OSError atrapa FileNotFoundError, ConnectionResetError, TimeoutError, etc. Aprovechá los nodos intermedios cuando quieras manejar una familia, pero nunca subas tanto que termines atrapando cosas que no sabés recuperar.

Excepciones estándar más frecuentes en backend y qué señalan:

Raising: lanzar bien

raise acepta una instancia o una clase (que se instancia sin argumentos). Lanzá siempre con un mensaje accionable y el tipo más específico que describa el fallo.

def parse_amount(raw: str) -> int:
    if not raw.isdigit():
        raise ValueError(f"amount must be a positive integer, got {raw!r}")
    return int(raw)

Dentro de un except, un raise sin argumentos re-lanza la excepción actual conservando su traceback original: es la forma correcta de “loguear y propagar” sin perder información.

try:
    do_work()
except OSError:
    logger.exception("do_work failed")  # loguea con stack completo
    raise                               # re-lanza intacta, mismo traceback

Handling: capturar bien

Capturá el tipo más específico posible y lo más cerca del origen posible. Un except puede listar varios tipos en una tupla, y podés bindear la instancia con as.

try:
    resp = client.get(url, timeout=5.0)
    resp.raise_for_status()
except (ConnectionError, TimeoutError) as err:
    # transitorio: candidato a retry
    raise UpstreamUnavailable(str(err)) from err
except HTTPStatusError as err:
    # terminal: 4xx, no reintentar
    raise UpstreamRejected(err.response.status_code) from err

El orden importa: Python evalúa los except de arriba abajo y usa el primero compatible. Poné los tipos más específicos antes que sus bases, o el base los eclipsará.

User-defined exceptions: jerarquía de dominio

Definí una excepción propia cuando el error cruza una frontera de capa y el llamador necesita semántica para actuar (mapear a HTTP, decidir retry, distinguir recuperable de fatal). Enraizá todo en una base común para poder capturar la familia sin acoplarte a tipos de infraestructura (psycopg, httpx).

class OrderError(Exception):
    """Base de todos los errores de dominio de pedidos."""


class OrderNotFound(OrderError):
    def __init__(self, order_id: str):
        self.order_id = order_id
        super().__init__(f"order {order_id} not found")


class PaymentDeclined(OrderError):
    def __init__(self, order_id: str, reason: str):
        self.order_id = order_id
        self.reason = reason
        super().__init__(f"payment declined for {order_id}: {reason}")

Guardá datos estructurados como atributos (order_id, reason), no solo en el string: el handler los usa para logs con contexto y para construir el body de error sin parsear texto. Llamá siempre a super().__init__(...) para que args y str(exc) queden bien formados.

BaseException vs Exception

Todo lo que herede directamente de BaseException (y no de Exception) existe precisamente para que los handlers ordinarios no lo atrapen: KeyboardInterrupt (Ctrl-C), SystemExit (sys.exit()), GeneratorExit (cierre de generador). Si escribís except Exception: no capturás ninguno de esos, que es exactamente lo que querés: un Ctrl-C debe poder parar el proceso aunque estés dentro de un bloque de manejo.

Un except: desnudo o except BaseException: atrapa todo, incluidos esos señales de control, y por eso están prácticamente prohibidos: haría que tu servicio ignore un pedido de shutdown. Regla: hereda tus excepciones de Exception, captura Exception como máximo techo, nunca BaseException.

else y finally

try:
    resp = call_api()          # solo lo que puede fallar
except NetworkError as err:
    raise UpstreamUnavailable() from err
else:
    return resp.json()         # corre SOLO si el try no lanzó
finally:
    span.finish()              # corre SIEMPRE (éxito, excepción o return)

Gotcha crítico de finally: un return, break o continue dentro de finally descarta cualquier excepción en vuelo — la excepción que se estaba propagando desaparece silenciosamente. Nunca hagas return desde finally.

def broken():
    try:
        raise ValueError("boom")
    finally:
        return "swallowed"   # la ValueError se pierde; la función devuelve "swallowed"

Context managers: cleanup que no se olvida

Para liberar recursos preferí un context manager (with) sobre un finally a mano: es componible y no se puede olvidar. Un context manager es cualquier objeto con __enter__ / __exit__; __exit__(exc_type, exc, tb) recibe la excepción si la hubo y puede suprimirla devolviendo True (rara vez lo querés).

from contextlib import contextmanager

@contextmanager
def db_transaction(conn):
    tx = conn.begin()
    try:
        yield tx
    except Exception:
        tx.rollback()   # cualquier fallo -> rollback
        raise           # y propaga: el llamador decide
    else:
        tx.commit()     # sin excepción -> commit
    # nota: no capturamos para tragar; re-lanzamos siempre

with db_transaction(conn) as tx:
    tx.execute(...)     # si esto lanza, hay rollback + propagación automáticos

contextlib.suppress reemplaza el patrón try/except/pass cuando ignorar un tipo concreto es realmente lo correcto (idempotencia):

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove(tmp_path)   # si no existe, no pasa nada; cualquier otro OSError propaga

Exception chaining: preservar el root cause (raise from)

Al envolver un error de infraestructura en uno de dominio, encadená siempre con from para no perder la causa original.

try:
    row = db.fetch_one(query, order_id)
except psycopg.Error as err:
    # el dominio no debe conocer psycopg; envolvemos preservando la causa.
    raise OrderRepositoryError(f"failed loading order {order_id}") from err

El antipatrón a evitar es raise DomainError(str(err)): convierte la causa en un string, tira el traceback y el tipo original, y rompe la cadena de depuración.

Desde Python 3.11 también podés anotar una excepción sin envolverla usando add_note() (PEP 678): agrega contexto que aparece en el traceback sin tocar tipo ni causa.

try:
    process(batch)
except Exception as err:
    err.add_note(f"batch_id={batch.id} tenant={batch.tenant}")
    raise

tracebacks: extraer información del fallo (módulo traceback)

El traceback es el objeto que registra la pila en el momento del raise; vive en exc.__traceback__. El módulo traceback lo convierte en texto o estructura sin necesidad de dejar que la excepción llegue a stderr.

import traceback

try:
    risky()
except Exception as err:
    # string completo (tipo + mensaje + stack), igual a lo que imprime el intérprete
    tb_str = traceback.format_exc()
    # imprimir a un stream concreto
    traceback.print_exc(file=sys.stderr)
    # objeto estructurado, serializable y navegable
    tbe = traceback.TracebackException.from_exception(err)
    for line in tbe.format():
        logger.error(line.rstrip())

APIs clave:

En producción, sin embargo, casi nunca llamás a traceback a mano: usás logging. logger.exception("msg") equivale a logger.error("msg", exc_info=True) y adjunta el traceback de la excepción en curso automáticamente. Reservá el módulo traceback para cuando necesitás el stack como dato (serializarlo, enviarlo a un sistema de errores, formatearlo distinto).

logger.exception("charge failed for order %s", order_id)   # nivel ERROR + stack

assert: contratos internos, no validación de input

assert cond, msg lanza AssertionError si cond es falsa. Es para invariantes internos que “nunca deberían fallar” (contratos de desarrollo, precondiciones que garantiza otro código), no para validar datos externos.

El motivo es dramático: ejecutar Python con el flag -O (optimizado, o PYTHONOPTIMIZE=1) elimina todos los assert. Si validás input de usuario con assert user.is_admin, en producción con -O esa comprobación desaparece y el control de acceso deja de existir.

# MAL: validación de seguridad/input con assert -> se borra con -O
assert user.is_admin, "forbidden"

# BIEN: input/autorización con raise explícito
if not user.is_admin:
    raise PermissionDenied(user.id)

# BIEN: assert para un invariante interno imposible-de-violar-si-el-código-es-correcto
def split_even(items: list) -> tuple[list, list]:
    a, b = items[::2], items[1::2]
    assert len(a) + len(b) == len(items)   # sanity check de desarrollo
    return a, b

En tests, en cambio, assert es idiomático (pytest reescribe los asserts para dar mensajes ricos). Para verificar que un bloque lanza lo esperado, pytest.raises:

import pytest

def test_charge_rejects_negative():
    with pytest.raises(ValueError, match="positive integer"):
        parse_amount("-5")
    # también podés inspeccionar la excepción capturada:
    with pytest.raises(OrderNotFound) as exc_info:
        load_order("nope")
    assert exc_info.value.order_id == "nope"

EAFP vs LBYL

Python favorece EAFP porque es atómico: la operación y su fallo son un solo paso, sin ventana para que el estado cambie por debajo. LBYL sobre recursos externos introduce TOCTOU (time-of-check to time-of-use): el archivo puede borrarse entre os.path.exists(p) y open(p), la fila entre el SELECT de comprobación y el INSERT. En código concurrente o de I/O, el check da falsa seguridad.

LBYL es aceptable solo para comprobaciones en memoria, libres de carrera, y en el hot path cuando la excepción sería el caso común y frecuente (lanzar tiene costo de setup). Elegí por concurrencia y costo: EAFP para todo lo que toque estado compartido/externo; LBYL solo cuando el check es race-free. En ambos casos, captura el tipo específico.

Patrones de producción

Reglas duras destiladas de todo lo anterior:

  1. No tragues excepciones. Un except Exception: pass es la causa número uno de bugs “imposibles”: el fallo se vuelve invisible y el estado se corrompe en silencio. Si capturás amplio, logueá con exc_info y re-lanzá o convertí.
  2. Captura estrecho y cerca del origen. Maneja solo lo que realmente sabés recuperar; el resto propaga con su cadena intacta.
  3. Una sola barrera de último recurso. Un except Exception top-level (middleware de request, loop de worker) que loguea el traceback completo con un correlation id, emite una métrica y devuelve un error sanitizado. Nunca decide “seguir como si nada”.
  4. No filtres el stack al cliente. Logueá el stack completo del lado servidor; devolvé al cliente un mensaje público estable y un código de error machine-readable, jamás el traceback ni detalles internos.
  5. Traducí infra → dominio en la frontera, con from para preservar el root cause. La lógica de negocio no debe conocer psycopg/httpx.
  6. Timeout siempre en llamadas externas. Sin timeout, un socket colgado agota el pool de workers y convierte una degradación parcial en caída total.
  7. Retries con criterio: reintentá solo transitorios (timeout, connection reset, 502/503/504), nunca terminales (4xx, auth, validación); capá los reintentos; backoff exponencial con jitter (evita el thundering herd); solo operaciones idempotentes (o con idempotency key). Añadí un circuit breaker para que una dependencia caída falle rápido.
  8. Cleanup con context managers, no finally a mano, siempre que sea un recurso.

Mapeo centralizado en la capa web (un único lugar, sin try/except con status hardcodeados dispersos por las rutas):

# FastAPI: un handler por familia de dominio -> status + body limpio + log server-side
from fastapi import Request
from fastapi.responses import JSONResponse

def register_error_handlers(app):
    @app.exception_handler(OrderNotFound)
    async def _not_found(request: Request, exc: OrderNotFound):
        logger.warning("order not found: %s", exc.order_id)
        return JSONResponse(status_code=404,
                            content={"error": "order_not_found", "detail": str(exc)})

    @app.exception_handler(PaymentDeclined)
    async def _declined(request: Request, exc: PaymentDeclined):
        logger.info("payment declined: %s (%s)", exc.order_id, exc.reason)
        return JSONResponse(status_code=402,
                            content={"error": "payment_declined", "detail": exc.reason})

    @app.exception_handler(Exception)   # barrera de último recurso
    async def _unhandled(request: Request, exc: Exception):
        logger.exception("unhandled error on %s", request.url.path)   # stack completo
        return JSONResponse(status_code=500,
                            content={"error": "internal_error"})       # sin detalles internos

ExceptionGroup y except* (3.11+)

Cuando varias operaciones fallan a la vez (p. ej. asyncio.TaskGroup donde caen múltiples tareas), Python 3.11 agrupa los fallos en un ExceptionGroup. Se manejan con except*, que captura y des-agrupa por tipo, permitiendo que un mismo bloque trate un subconjunto y re-propague el resto.

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch_a())
        tg.create_task(fetch_b())
except* TimeoutError as eg:
    # eg es un ExceptionGroup solo con los TimeoutError del grupo
    logger.warning("%d subtasks timed out", len(eg.exceptions))
except* ValueError as eg:
    logger.error("%d subtasks had bad values", len(eg.exceptions))

Ejercicios

Ejercicio 1 — Traducción infra → dominio con chaining

Tenés una función de repositorio que consulta una DB con una librería que puede lanzar ConnectionError (transitorio) o KeyError al mapear una fila incompleta. Escribí load_user(user_id) que envuelva ambos en excepciones de dominio (UserRepositoryUnavailable para el transitorio, UserDataCorrupted para el mapeo) preservando el root cause. La base de la jerarquía debe ser UserRepositoryError.

Solución
class UserRepositoryError(Exception):
    """Base de errores del repositorio de usuarios."""

class UserRepositoryUnavailable(UserRepositoryError):
    """Fallo transitorio de conectividad; candidato a retry."""

class UserDataCorrupted(UserRepositoryError):
    """La fila existe pero no tiene la forma esperada; NO reintentar."""


def load_user(user_id: str) -> dict:
    try:
        row = db.fetch_one("SELECT * FROM users WHERE id = %s", user_id)
        return {"id": row["id"], "email": row["email"]}
    except ConnectionError as err:
        raise UserRepositoryUnavailable(f"db unreachable loading {user_id}") from err
    except KeyError as err:
        # KeyError del mapeo: columna faltante -> dato corrupto, no reintentable.
        raise UserDataCorrupted(f"row for {user_id} missing column {err}") from err

Claves: from err en ambos ramos preserva __cause__; se distingue transitorio de terminal por tipo, no por mensaje; la base común permite except UserRepositoryError aguas arriba sin conocer la DB.

Ejercicio 2 — El finally que se traga la excepción

El siguiente código “funciona” pero oculta fallos. Identificá el bug y corregilo para que las excepciones se propaguen y el recurso se libere igual.

def read_config(path):
    f = open(path)
    try:
        return parse(f.read())
    finally:
        return f.close()   # ???
Solución

El return f.close() dentro de finally descarta cualquier excepción que parse() haya lanzado (y además devuelve el valor de close(), que es None). El finally con return gana siempre sobre la excepción en vuelo.

def read_config(path):
    with open(path) as f:          # cierre garantizado por el context manager
        return parse(f.read())     # si parse() lanza, propaga; el archivo se cierra igual

Si por alguna razón no pudieras usar with, el finally debe cerrar sin return:

def read_config(path):
    f = open(path)
    try:
        return parse(f.read())
    finally:
        f.close()   # libera, pero no interfiere con la excepción en vuelo

Ejercicio 3 — Retry acotado solo para transitorios

Implementá call_with_retries(fn, *, retries=3, base=0.1) que reintente fn() solo ante TimeoutError o ConnectionError, con backoff exponencial + jitter, hasta retries intentos. Cualquier otra excepción debe propagar inmediatamente. Si se agotan los intentos, re-lanzá el último error preservando la causa.

Solución
import random
import time

TRANSIENT = (TimeoutError, ConnectionError)

def call_with_retries(fn, *, retries=3, base=0.1):
    last: Exception | None = None
    for attempt in range(1, retries + 1):
        try:
            return fn()
        except TRANSIENT as err:      # solo transitorios; 4xx/validación propagan
            last = err
            if attempt == retries:
                break
            sleep = base * (2 ** (attempt - 1)) + random.uniform(0, base)  # backoff+jitter
            logger.warning("attempt %d/%d failed: %s; retrying in %.2fs",
                           attempt, retries, err, sleep)
            time.sleep(sleep)
    raise RuntimeError(f"exhausted {retries} retries") from last

Claves: la tupla TRANSIENT restringe qué se reintenta; un ValueError o un 4xx (no listado) sale del loop de inmediato por no ser capturado; el jitter rompe la sincronización de reintentos; el error final encadena el último transitorio con from last.

Ejercicio 4 — Serializar un traceback para un log estructurado

Querés loguear en JSON el error de una tarea de fondo, incluyendo el stack como texto, sin dejar que la excepción reviente el worker. Escribí capture_error(err) -> dict que devuelva {"type", "message", "traceback"} usando el módulo traceback.

Solución
import traceback

def capture_error(err: BaseException) -> dict:
    tbe = traceback.TracebackException.from_exception(err)
    return {
        "type": type(err).__name__,
        "message": str(err),
        "traceback": "".join(tbe.format()),   # stack completo como string
    }

# uso en un worker:
try:
    run_job(job)
except Exception as err:
    logger.error("job failed", extra={"error": capture_error(err)})
    # decidir: reencolar, mandar a dead-letter, etc. — pero NO tragar en silencio

TracebackException.from_exception produce una representación estructurada que no retiene los frames vivos (evita fugas de memoria por referencias a locals) y es serializable. Alternativa de una línea si solo querés el string del error en curso: traceback.format_exc().

Ejercicio 5 — assert vs validación real

Este endpoint usa assert para autorizar. Explicá por qué es un agujero de seguridad y reescribilo.

def delete_account(user, target_id):
    assert user.is_admin, "only admins can delete accounts"
    repo.delete(target_id)
Solución

Con el flag -O (o PYTHONOPTIMIZE=1), Python elimina todos los assert en tiempo de compilación. Un despliegue de producción que corra python -O deja el assert user.is_admin fuera del bytecode: cualquier usuario podría borrar cuentas. assert es para invariantes internos de desarrollo, nunca para autorización ni validación de input.

class PermissionDenied(Exception):
    def __init__(self, user_id: str):
        self.user_id = user_id
        super().__init__(f"user {user_id} is not allowed to delete accounts")

def delete_account(user, target_id):
    if not user.is_admin:
        raise PermissionDenied(user.id)   # control real, no se optimiza fuera
    repo.delete(target_id)

La capa web mapea PermissionDenied -> 403. La comprobación ahora es parte de la lógica, inmune a -O.

Preguntas tipo entrevista (EN)

Q1: Why is except Exception: (or bare except:) considered dangerous, and what exactly does it catch that you almost never want?

Q2: What’s the difference between raise NewError(...) from err, implicit chaining, and raise ... from None? Why does it matter in production?

Q3: When should you define a custom exception hierarchy instead of reusing built-ins, and how does it map to a web API?

Q4: Walk me through try/except/else/finally. What goes in else and what are the finally gotchas?

Q5: EAFP vs LBYL — which is idiomatic in Python and when does the choice actually matter?

Q6: A downstream HTTP dependency is flaky. How do you handle it with retries, timeouts and exceptions without making things worse?

Q7: How do you get structured information out of an exception’s traceback, and where does the traceback module fit versus logging?

Q8: When is assert appropriate, and what’s the production footgun?

Referencias