Crear una excepción propia no es solo escribir class MiError(Exception): pass. Esa línea funciona, pero la mayoría de las veces es una oportunidad desperdiciada. Una excepción bien diseñada es una API de error: le dice al caller qué salió mal, por qué, y le da herramientas para reaccionar de forma diferenciada.
La regla de oro: hereda siempre de Exception o de una subclase suya, nunca directamente de BaseException. BaseException es el ancestro de SystemExit, KeyboardInterrupt y GeneratorExit —excepciones que el intérprete usa para gestionar el ciclo de vida del proceso—. Si heredas de ella, un except MiError accidental puede tragarse un Ctrl+C. Exception es el contrato correcto para errores de aplicación.
La segunda decisión es cuándo crear jerarquías versus excepciones sueltas. La respuesta depende del caller. Si quien llama a tu código necesita poder decir “quiero capturar cualquier error de red, sea cual sea”, necesitas una clase base NetworkError. Si solo hay un tipo de fallo posible y nunca habrá más, una clase simple es suficiente. El antipatrón clásico es el extremo opuesto: una sola AppError genérica para todo. Acaba siendo inútil porque el caller no puede distinguir un error de validación de uno de base de datos sin inspeccionar el mensaje con str(e) —eso es parsear texto, no manejar excepciones—.
Los atributos informativos en __init__ son lo que convierte una excepción en algo accionable. En lugar de meter todo en un string, expón los datos estructurados. El caller puede leer error.status_code y decidir si reintentar o abortar, sin tocar el mensaje humano. Implementa __str__ cuando el formato por defecto (Exception.__str__ usa args[0]) no es suficientemente descriptivo, o cuando quieres que print(e) en un log muestre algo coherente sin que el caller tenga que construirlo.
# exceptions.py
class ClientError(Exception):
"""Base para todos los errores originados en el cliente (4xx)."""
class ServerError(Exception):
"""Base para todos los errores originados en el servidor (5xx)."""
class APIError(ClientError):
"""Error con código de estado HTTP y cuerpo de respuesta estructurado."""
def __init__(self, status_code: int, message: str, endpoint: str = "") -> None:
self.status_code = status_code
self.message = message
self.endpoint = endpoint
# Pasamos algo a super().__init__ para que repr() y logging sean útiles
# sin depender únicamente de __str__.
super().__init__(message)
def __str__(self) -> str:
base = f"[HTTP {self.status_code}] {self.message}"
if self.endpoint:
return f"{base} (endpoint: {self.endpoint})"
return base
class RateLimitError(APIError):
"""El servidor rechazó la petición por exceso de tasa."""
def __init__(self, retry_after: int, endpoint: str = "") -> None:
self.retry_after = retry_after
super().__init__(
status_code=429,
message=f"Rate limit alcanzado. Reintenta en {retry_after}s.",
endpoint=endpoint,
)
class AuthenticationError(APIError):
"""Credenciales inválidas o token expirado."""
def __init__(self, endpoint: str = "") -> None:
super().__init__(
status_code=401,
message="Autenticación fallida.",
endpoint=endpoint,
)
# --- Código que lanza las excepciones ---
import random
def call_api(endpoint: str) -> dict:
"""Simula llamadas HTTP con distintos fallos posibles."""
scenario = random.choice(["ok", "rate_limit", "auth", "server"])
if scenario == "rate_limit":
raise RateLimitError(retry_after=30, endpoint=endpoint)
if scenario == "auth":
raise AuthenticationError(endpoint=endpoint)
if scenario == "server":
# Un error de servidor usa la jerarquía ServerError, no ClientError
raise ServerError(f"Internal server error en {endpoint}")
return {"data": "respuesta real"}
# --- Caller con granularidad de captura ---
def fetch_user(user_id: int) -> dict | None:
endpoint = f"/users/{user_id}"
try:
return call_api(endpoint)
except RateLimitError as e:
# Podemos leer e.retry_after directamente, sin parsear strings
print(f"Throttled. Esperando {e.retry_after}s antes de reintentar.")
return None
except AuthenticationError:
# No hay nada que reintentar; el problema es de configuración
raise
except ClientError as e:
# Cualquier otro 4xx que no anticipamos explícitamente
print(f"Error de cliente no esperado: {e}")
return None
except ServerError:
# Los 5xx son transitorios; relanzamos para que el caller decida
raise
if __name__ == "__main__":
result = fetch_user(42)
print(result)
Qué hace cada decisión de diseño
La jerarquía tiene dos ramas independientes, ClientError y ServerError, porque la semántica es distinta: los errores 4xx son culpa del caller, los 5xx son culpa del servidor. Un caller que solo quiere “atrapar todos los errores de cliente” puede escribir except ClientError sin saber nada de RateLimitError ni AuthenticationError. Eso es exactamente lo que hace la línea except ClientError as e al final del bloque: es la red de seguridad para 4xx que todavía no tienen clase propia.
Fíjate en que super().__init__(message) se llama siempre. Es tentador no llamarlo, pero si omites ese paso, logging.exception(e) y traceback.format_exc() muestran el tipo de la excepción sin ningún mensaje, y los sistemas de monitorización como Sentry capturan una cadena vacía. Pasar message a args[0] es el contrato que respeta el ecosistema Python.
__str__ en APIError enriquece la representación sin romper nada: quien haga str(e) ve el código HTTP y el endpoint; quien acceda a e.status_code y e.message sigue teniendo los datos estructurados. Los dos contratos coexisten.
RateLimitError hereda de APIError y fija el status_code en 429 internamente. El caller no necesita conocer ese código para manejar el rate limiting —solo necesita saber que es un RateLimitError y que tiene retry_after—. La jerarquía encapsula la convención HTTP, no la expone.
Errores que debes conocer
Error: no llamar a super().__init__() con ningún argumento, dejando args vacío.
# ❌ Wrong
class APIError(Exception):
def __init__(self, status_code, message):
self.status_code = status_code
self.message = message
# super().__init__() sin argumentos → args = ()
# ✅ Right
class APIError(Exception):
def __init__(self, status_code, message):
self.status_code = status_code
self.message = message
super().__init__(message) # args = (message,)
Con args vacío, repr(e) devuelve APIError(), los logs estructurados pierden el mensaje, y pickle puede fallar al serializar la excepción en sistemas distribuidos.
Error: crear una jerarquía tan profunda que las clases intermedias no añaden semántica.
# ❌ Wrong class AppError(Exception): pass class NetworkError(AppError): pass class HTTPError(NetworkError): pass class ClientHTTPError(HTTPError): pass class NotFoundError(ClientHTTPError): pass # 5 niveles para algo simple # ✅ Right class NetworkError(Exception): pass class NotFoundError(NetworkError): pass # directo, legible, suficiente
Cada nivel de herencia es una promesa al caller de que hay una razón semántica para distinguir ese nivel. Si no la hay, el árbol se vuelve ruido que nadie captura.
Error: heredar de BaseException creyendo que es más genérico o más correcto.
# ❌ Wrong class AppError(BaseException): pass # except Exception no lo captura # ✅ Right class AppError(Exception): pass # se integra con el ecosistema estándar
Un except Exception en cualquier punto de la cadena de llamadas —incluyendo frameworks, tests y manejadores de tareas async— ignorará silenciosamente tu excepción si hereda de BaseException directamente, con consecuencias impredecibles en producción.
N° 79