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:
ValueError: el tipo es correcto pero el valor no (unint("abc"), un enum inválido). Típico en validación.TypeError: operación aplicada a un tipo incompatible; casi siempre un bug, no un input.KeyError/IndexError: acceso a una clave o índice ausente.AttributeError: acceso a un atributo inexistente; suele delatar unNoneinesperado.OSErrory su familia: fallos de I/O, red y filesystem (aquí vivenConnectionError,TimeoutError).RuntimeError: estado inválido genérico cuando no hay algo más específico.Exception: la raíz; se usa como base de jerarquías propias, no como red que se traga todo.
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)
trycontiene únicamente la operación que puede fallar. Untryinflado hace que elexceptatrape excepciones de código que no debía proteger (p. ej. el parseo de la respuesta, no la llamada de red).elsecorre solo si eltryno lanzó. Es la herramienta para sacar el happy path del alcance delexcept: si elresp.json()estuviera dentro deltry, unJSONDecodeErrorse atribuiría por error aNetworkError.finallycorre siempre, para liberar recursos.
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.
raise DomainError(...) from err— encadenamiento explícito: setea__cause__ = erry__suppress_context__ = True. El traceback imprime “The above exception was the direct cause of the following exception”. Es lo que querés al hacer wrap-and-raise.raise DomainError(...)dentro de unexcept— encadenamiento implícito: Python guarda la excepción previa en__context__y el traceback muestra “During handling of the above exception, another exception occurred”.raise DomainError(...) from None— setea__suppress_context__ = Truey oculta la cadena. Úsalo deliberada y raramente, cuando la causa interna es ruido o filtra detalle de implementación (p. ej. re-lanzar unaValidationErrorlimpia a partir de unKeyErrorde una tabla de lookup).
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:
traceback.format_exc()→strcon el traceback actual (útil para meterlo en un log JSON).traceback.print_exc(file=...)→ imprime el traceback actual.traceback.format_exception(exc)→ lista de líneas a partir de una excepción concreta (3.10+ acepta solo el objeto).traceback.TracebackException→ representación estructurada y ligera que podés capturar, pasar entre procesos o formatear después, sin retener frames vivos (evita fugas de memoria por referencias a locals).traceback.extract_tb(tb)→ lista deFrameSummary(archivo, línea, función) para inspección programática.
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
- LBYL (“look before you leap”): comprobar precondiciones antes de actuar (
if key in d: ...,if os.path.exists(p): open(p)). - EAFP (“easier to ask forgiveness than permission”): intentar la operación y capturar la excepción específica (
try: d[key] except KeyError: ...).
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:
- No tragues excepciones. Un
except Exception: passes 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á conexc_infoy re-lanzá o convertí. - Captura estrecho y cerca del origen. Maneja solo lo que realmente sabés recuperar; el resto propaga con su cadena intacta.
- Una sola barrera de último recurso. Un
except Exceptiontop-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”. - 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.
- Traducí infra → dominio en la frontera, con
frompara preservar el root cause. La lógica de negocio no debe conocerpsycopg/httpx. - 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.
- 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.
- Cleanup con context managers, no
finallya 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?
- ❌ Wrong / trap: “Use
except:orexcept Exception:at the top of the handler so the service never crashes.” Fails because a bareexcept:catchesBaseException— includingKeyboardInterrupt(Ctrl-C) andSystemExit(sys.exit()), so you can’t stop the process cleanly; andexcept Exception:that onlypasses swallows real bugs (KeyError,AttributeError) with no log and no re-raise, hiding incidents and corrupting state silently. - ✅ Correct: Catch the narrowest exception type you can actually handle (
except httpx.TimeoutException). Never use bareexcept:. If you must have a broad safety net, catchException(notBaseException), and always log withexc_infoand re-raise or convert — never silentlypass. - ⭐ Optimal (senior):
KeyboardInterrupt,SystemExitandGeneratorExitinherit directly fromBaseException, precisely so ordinary handlers don’t swallow control-flow/shutdown signals. In production I catch narrowly at the boundary where recovery is meaningful; a top-levelexcept Exceptiononly exists as a single last-resort barrier (e.g. a request middleware or worker loop) that logs the full traceback with a correlation id, emits a metric, and returns a sanitized error — it never decides to continue as if nothing happened. Swallowing exceptions is the number-one cause of “impossible” production bugs because the failure mode becomes invisible.
Q2: What’s the difference between raise NewError(...) from err, implicit chaining, and raise ... from None? Why does it matter in production?
- ❌ Wrong / trap: “Just
raise MyError('failed')inside theexcept; Python figures out the original error.” Partially true but a trap: withoutfrom erryou rely on implicit chaining (__context__), and if anyone later addsfrom Noneor re-raises carelessly you lose the root cause. Worse,except X: raise MyError(str(err))throws away the traceback and the typed original entirely. - ✅ Correct: Use explicit chaining
raise MyError(...) from err: it sets__cause__to the original and prints “The above exception was the direct cause of the following”. Implicit chaining happens automatically when you raise inside anexceptblock: the previous exception is stored in__context__and shown as “During handling of the above exception, another occurred”.raise ... from Nonesets__suppress_context__and hides the chain. - ⭐ Optimal (senior):
from errsets__cause__and__suppress_context__=True(verified:__cause__and__context__both point to the original,__suppress_context__isTrue), which is what you want when wrapping infra errors in domain errors — the traceback keeps the full causal chain so on-call sees the true root cause, not just the wrapper. I usefrom Nonedeliberately and rarely: to suppress a noisy internal cause when it leaks implementation detail or is misleading (e.g. re-raising a cleanValidationErrorfrom aKeyErrorin a lookup table). The rule: never destroy the chain by accident — losing__cause__/__context__is losing the debugging trail.
Q3: When should you define a custom exception hierarchy instead of reusing built-ins, and how does it map to a web API?
- ❌ Wrong / trap: “Raise
Exception('order not found')or reuseValueErroreverywhere; strings are enough.” Fails because callers can only match on message strings (fragile), can’t distinguish recoverable from fatal, and the web layer can’t map errors to status codes without parsing text. - ✅ Correct: Define a domain base (
class OrderError(Exception)) with specific subclasses (OrderNotFound,PaymentDeclined). Callers catch the base to handle a whole family or a subclass for precision. The web layer registers one handler per family and maps to HTTP status. - ⭐ Optimal (senior): A custom hierarchy is worth it when the error crosses a layer boundary and needs semantics the caller acts on. I root all domain errors in a single base so infrastructure code can
except OrderErrorwithout coupling topsycopg/httpxtypes. In FastAPI I register exception handlers that mapOrderNotFound -> 404,PaymentDeclined -> 402,PaymentGatewayError -> 502, attach a stable machine-readableerror code, log the full internal chain server-side, and return only the sanitized public message. This keeps the mapping in one place (observability + no leakage) instead of scatteringtry/exceptwith hardcoded status codes across routes.
Q4: Walk me through try/except/else/finally. What goes in else and what are the finally gotchas?
- ❌ Wrong / trap: “Put everything in the
try, and usefinallytoreturna default on error.” Fails: a bloatedtrymakes theexceptcatch exceptions from code that wasn’t supposed to be guarded (e.g. the response parsing, not the network call). And areturn/break/continueinsidefinallysilently swallows any in-flight exception — it never propagates. - ✅ Correct:
tryholds only the operation that can fail;elseholds the code that must run only if no exception occurred (keeps it out of theexcept’s reach);excepthandles specific failures;finallyalways runs for cleanup. Don’treturnfromfinally. - ⭐ Optimal (senior):
elseis the tool that keeps the happy path from being accidentally caught — e.g.try: resp = call()/except NetworkError: .../else: return resp.json(), so aJSONDecodeErrorin parsing isn’t misattributed to the network.finallyruns even onreturn/exception and is for release-no-matter-what; but areturnorbreakinfinallysuppresses the propagating exception (a classic hidden bug) and swallowing there hides failures. For resource cleanup I prefer context managers (with) over hand-writtenfinallybecause they’re composable and can’t be forgotten;finallystays for cross-cutting concerns like timing/metrics.
Q5: EAFP vs LBYL — which is idiomatic in Python and when does the choice actually matter?
- ❌ Wrong / trap: “Always check first:
if key in d and os.path.exists(path): ...before acting — checking is safer than catching.” Fails on two fronts: it’s un-Pythonic and, more importantly, it introduces TOCTOU race conditions — the file can be deleted betweenos.path.existsandopen, so the check gives false safety in concurrent/IO code. - ✅ Correct: Python favors EAFP (“easier to ask forgiveness than permission”): attempt the operation and catch the specific exception (
try: return d[key] except KeyError: ...,try: open(path) except FileNotFoundError: ...). It’s idiomatic and avoids the race window. - ⭐ Optimal (senior): EAFP is preferred because it’s atomic — the operation and its failure are one step, so there’s no gap for state to change underneath you (critical for filesystem/DB/network). LBYL is fine for cheap in-memory checks where no race exists and where an exception would be part of normal expected flow on the hot path (exceptions have setup cost when raised frequently). The real senior nuance: choose by concurrency and cost — EAFP for anything touching shared/external state, LBYL only when the check is race-free and the “error” case is common enough that raising would dominate. Either way, catch the specific exception, never a broad one.
Q6: A downstream HTTP dependency is flaky. How do you handle it with retries, timeouts and exceptions without making things worse?
- ❌ Wrong / trap: “Wrap the call in
while True: try: ... except Exception: continueso it keeps retrying until it works.” Fails catastrophically: no timeout means a hung connection blocks forever; catchingExceptionretries non-retryable errors (a 400/validation bug retried infinitely); no backoff creates a retry storm that amplifies the outage; and it can retry non-idempotent writes, double-charging. - ✅ Correct: Set explicit connect/read timeouts on every external call, retry only specific transient exceptions (timeouts, connection errors, 502/503/504), cap retries (e.g. 3), use exponential backoff with jitter, and only retry idempotent operations.
- ⭐ Optimal (senior): Timeout is non-negotiable — without it a single stuck socket exhausts the worker pool (cascading failure). I distinguish retryable (network timeout, connection reset, 503) from terminal (4xx, auth, validation) and only retry the former; retrying a
400just burns budget. Backoff needs jitter to avoid synchronized retry storms (thundering herd). For writes I require an idempotency key before enabling retries so I don’t duplicate side effects. Beyond retries I add a circuit breaker so a persistently-down dependency fails fast instead of tying up threads, surface it as a domain error mapped to502/503, and emit metrics (retry count, breaker state, latency) so the flakiness is observable rather than silently absorbed. The failure mode I’m designing against is turning a partial outage into a full one.
Q7: How do you get structured information out of an exception’s traceback, and where does the traceback module fit versus logging?
- ❌ Wrong / trap: “Do
print(err)orstr(err)— that gives you the stack trace.” Fails:str(err)is only the message, not the stack. You lose the call chain and file/line info that actually locates the bug. Andprinting to stdout in a service is not observable. - ✅ Correct: The stack lives in
err.__traceback__. Use thetracebackmodule to render it:traceback.format_exc()returns the current traceback as a string,traceback.print_exc()prints it, andtraceback.format_exception(err)works from a specific exception object. In application code, preferlogger.exception("msg")(orlogger.error("msg", exc_info=True)) which attaches the traceback of the exception currently being handled. - ⭐ Optimal (senior): In production I log via
logger.exceptionat the boundary so the full stack goes to the log pipeline with a correlation id, and the client only sees a sanitized message. I reach for thetracebackmodule directly when I need the stack as data — e.g.traceback.TracebackException.from_exception(err)gives a structured, serializable representation that doesn’t retain live frames (avoids memory leaks from references to locals), which I can format into a JSON log field or ship to an error tracker.extract_tbgivesFrameSummaryobjects for programmatic inspection. Key discipline: capture the traceback where the exception is caught, don’t pass raw exception objects across threads/processes expecting the traceback to survive — serialize it first.
Q8: When is assert appropriate, and what’s the production footgun?
- ❌ Wrong / trap: “Use
assertto validate request input and permissions — it’s concise:assert user.is_admin/assert amount > 0.” Fails dangerously: running Python with-O(orPYTHONOPTIMIZE=1) strips everyassertat compile time. In an optimized production build those checks simply vanish — the authorization and input validation are gone, silently. - ✅ Correct:
assertis for internal invariants and developer-time sanity checks that should never fail if the code is correct — not for validating external input or enforcing security. For input/authorization use explicitif ... : raise SomeError(...). In tests,assertis idiomatic (pytest rewrites it for rich messages), andpytest.raisesverifies that a block raises the expected exception. - ⭐ Optimal (senior): I treat
assertas an executable comment about an invariant I believe is guaranteed by surrounding code (post-conditions, internal consistency), knowing it can be compiled out — so nothing load-bearing may depend on it running. Anything that must hold for correctness or security in production is an explicitraise. In tests I usepytest.raises(ExpectedError, match=...)and inspectexc_info.valueto assert on exception attributes, and I test the type and cause of raised errors, not just their message strings, so the contract (which the web layer maps to status codes) stays verified.
Referencias
- Python docs — Errors and Exceptions (tutorial)
- Python docs — Built-in Exceptions (jerarquía completa)
- Python docs —
tracebackmodule - Python docs —
contextlib(suppress,contextmanager) - Python docs —
logging.Logger.exception/exc_info - Python docs — The
assertstatement - PEP 3134 — Exception Chaining and Embedded Tracebacks (
__cause__/__context__) - PEP 409 — Suppressing exception context (
raise ... from None) - PEP 654 — Exception Groups and
except* - PEP 678 — Enriching Exceptions with Notes (
add_note)