En esta página

Manejo de errores

12 min lectura TextoCap. 3 — Python intermedio

¿Por qué manejar errores?

En el mundo real, los programas fallan. Los archivos no existen, la red falla, el usuario ingresa datos inválidos, la base de datos se cae. La diferencia entre código de producción y código de juguete está en cómo se manejan esos fallos.

Python usa un mecanismo de excepciones para el manejo de errores. Cuando algo sale mal, Python "lanza" una excepción que, si no se captura, termina el programa con un mensaje de error. Nuestro trabajo es anticipar los fallos y decidir cómo responder a cada uno.

La estructura try / except

# Forma básica
try:
    resultado = 10 / 0
except ZeroDivisionError:
    print("Error: no se puede dividir entre cero")

# Sin manejo, esto habría impreso:
# ZeroDivisionError: division by zero
# Y habría terminado el programa

# Capturar múltiples tipos de excepción
def convertir_a_int(texto: str) -> int | None:
    try:
        return int(texto)
    except ValueError:
        print(f"'{texto}' no es un número válido")
        return None
    except TypeError:
        print("Se esperaba una cadena de texto")
        return None

print(convertir_a_int("42"))     # 42
print(convertir_a_int("abc"))    # Error → None
print(convertir_a_int(None))     # Error → None

# Capturar múltiples excepciones en una línea
try:
    valor = int(input("Ingresa un número: "))
except (ValueError, EOFError):
    print("Entrada inválida")

Acceder al objeto de excepción

El objeto de excepción contiene información sobre el error:

try:
    lista = [1, 2, 3]
    print(lista[10])
except IndexError as e:
    print(f"Error de índice: {e}")
    print(f"Tipo: {type(e).__name__}")
    # Podemos re-lanzar con más contexto
    # raise RuntimeError("Índice fuera de rango en proceso crítico") from e

La estructura completa: try / except / else / finally

import json

def leer_configuración(ruta: str) -> dict:
    """
    Lee un archivo de configuración JSON.
    Demuestra el uso completo de try/except/else/finally.
    """
    archivo = None
    try:
        # Código que puede fallar
        archivo = open(ruta, "r", encoding="utf-8")
        datos = json.load(archivo)
    except FileNotFoundError:
        print(f"Archivo no encontrado: {ruta}")
        return {}
    except json.JSONDecodeError as e:
        print(f"JSON inválido en {ruta}: {e}")
        return {}
    except PermissionError:
        print(f"Sin permisos para leer: {ruta}")
        return {}
    else:
        # Se ejecuta SOLO si no hubo excepción en try
        print(f"Configuración cargada exitosamente desde {ruta}")
        return datos
    finally:
        # Se ejecuta SIEMPRE — con o sin excepción
        if archivo is not None:
            archivo.close()
            print("Archivo cerrado")

config = leer_configuración("config.json")

Excepciones comunes en Python

ValueError

Se lanza cuando una función recibe el tipo correcto pero un valor inapropiado:

# Casos que lanzan ValueError
int("abc")          # No es representable como int
float("infinito")   # No es un float válido
math.sqrt(-1)       # No definido en reales (usa cmath para complejos)

# Patrón de validación con ValueError
def calcular_descuento(precio: float, porcentaje: float) -> float:
    if precio < 0:
        raise ValueError(f"El precio no puede ser negativo: {precio}")
    if not 0 <= porcentaje <= 100:
        raise ValueError(f"El porcentaje debe estar entre 0 y 100: {porcentaje}")
    return precio * (1 - porcentaje / 100)

try:
    descuento = calcular_descuento(100, 150)
except ValueError as e:
    print(f"Error de valor: {e}")

TypeError

Se lanza cuando una operación se aplica a un tipo incorrecto:

try:
    resultado = "5" + 10  # No se pueden sumar str y int
except TypeError as e:
    print(f"TypeError: {e}")

try:
    for x in 42:  # Los enteros no son iterables
        print(x)
except TypeError as e:
    print(f"TypeError: {e}")

# Función con validación de tipos
def calcular_promedio(numeros: list[float]) -> float:
    if not isinstance(numeros, (list, tuple)):
        raise TypeError(f"Se esperaba list o tuple, no {type(numeros).__name__}")
    if len(numeros) == 0:
        raise ValueError("La lista no puede estar vacía")
    return sum(numeros) / len(numeros)

KeyError y IndexError

# KeyError — clave no existe en diccionario
config = {"host": "localhost", "puerto": 8080}
try:
    timeout = config["timeout"]
except KeyError as e:
    print(f"Clave no encontrada: {e}")
    timeout = 30  # valor por defecto

# Mejor patrón — usar .get()
timeout = config.get("timeout", 30)

# IndexError — índice fuera de rango
numeros = [1, 2, 3]
try:
    print(numeros[10])
except IndexError as e:
    print(f"Índice inválido: {e}")

FileNotFoundError y OSError

from pathlib import Path

def leer_archivo(ruta: str) -> str:
    try:
        return Path(ruta).read_text(encoding="utf-8")
    except FileNotFoundError:
        raise FileNotFoundError(f"El archivo no existe: {ruta}")
    except PermissionError:
        raise PermissionError(f"Sin permiso para leer: {ruta}")
    except OSError as e:
        raise OSError(f"Error del sistema al leer {ruta}: {e}") from e

Lanzar excepciones con raise

def dividir(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError("El divisor no puede ser cero")
    return a / b

# Re-lanzar la excepción actual (dentro de un except)
def procesar_datos(datos: list[int]) -> list[int]:
    try:
        return [100 // x for x in datos]
    except ZeroDivisionError:
        print("Advertencia: se encontró un cero en los datos")
        raise  # Re-lanza la misma excepción sin perder el traceback

# Re-lanzar como un tipo diferente (encadenamiento implícito)
def cargar_usuario(uid: int) -> dict:
    try:
        usuarios = {1: {"nombre": "Ana"}}
        return usuarios[uid]
    except KeyError:
        raise ValueError(f"Usuario con ID {uid} no encontrado")

Encadenamiento de excepciones

# raise X from Y — encadenamiento explícito (preserva causa original)
def conectar_bd(url: str) -> object:
    try:
        # Simular fallo de conexión
        raise ConnectionRefusedError("Puerto 5432 rechazado")
    except ConnectionRefusedError as e:
        raise RuntimeError(f"No se pudo conectar a la BD: {url}") from e

try:
    conectar_bd("postgresql://localhost/midb")
except RuntimeError as e:
    print(f"Error: {e}")
    print(f"Causado por: {e.__cause__}")

# raise X from None — suprimir la cadena (cuando no quieres mostrar el original)
def buscar_por_nombre(nombre: str) -> dict:
    base_de_datos = {"Ana": {"id": 1}, "Bruno": {"id": 2}}
    try:
        return base_de_datos[nombre]
    except KeyError:
        # No queremos revelar que usamos un dict internamente
        raise ValueError(f"Usuario no encontrado: {nombre}") from None

Excepciones personalizadas

Crear una jerarquía de excepciones propias es una buena práctica para aplicaciones grandes:

# Clase base para toda la aplicación
class TiendaError(Exception):
    """Error base de la aplicación de tienda."""
    def __init__(self, mensaje: str, codigo: int = 0) -> None:
        super().__init__(mensaje)
        self.codigo = codigo

    def __str__(self) -> str:
        return f"[{self.codigo}] {super().__str__()}"


class ProductoNoEncontrado(TiendaError):
    """El producto solicitado no existe."""
    pass


class StockInsuficiente(TiendaError):
    """No hay suficiente stock para la cantidad solicitada."""
    def __init__(self, producto: str, disponible: int, solicitado: int) -> None:
        super().__init__(
            f"Stock insuficiente para '{producto}': "
            f"{disponible} disponibles, {solicitado} solicitados",
            codigo=409
        )
        self.disponible = disponible
        self.solicitado = solicitado


class PrecioInvalido(TiendaError):
    """El precio especificado es inválido."""
    pass


# Usar las excepciones personalizadas
class Carrito:
    def __init__(self) -> None:
        self._items: dict[str, int] = {}
        self._catalogo: dict[str, dict] = {
            "P001": {"nombre": "Laptop", "precio": 1299.99, "stock": 5},
            "P002": {"nombre": "Mouse", "precio": 29.99, "stock": 0},
        }

    def agregar(self, producto_id: str, cantidad: int) -> None:
        if producto_id not in self._catalogo:
            raise ProductoNoEncontrado(
                f"Producto '{producto_id}' no existe en el catálogo",
                codigo=404
            )

        producto = self._catalogo[producto_id]
        if producto["stock"] < cantidad:
            raise StockInsuficiente(
                producto["nombre"],
                disponible=producto["stock"],
                solicitado=cantidad
            )

        self._items[producto_id] = self._items.get(producto_id, 0) + cantidad
        producto["stock"] -= cantidad

carrito = Carrito()

try:
    carrito.agregar("P001", 3)
    print("Producto agregado con éxito")
    carrito.agregar("P002", 1)  # Sin stock
except StockInsuficiente as e:
    print(f"Error de stock: {e}")
    print(f"  Disponible: {e.disponible}, Solicitado: {e.solicitado}")
except ProductoNoEncontrado as e:
    print(f"Error de catálogo: {e}")
except TiendaError as e:
    print(f"Error general de tienda: {e}")

Contexto con contextlib.suppress

Para casos donde quieres ignorar específicamente ciertos errores:

from contextlib import suppress

# En lugar de:
try:
    import ujson as json
except ImportError:
    import json

# Para ignorar silenciosamente (usar con cuidado)
with suppress(FileNotFoundError):
    Path("temporal.txt").unlink()  # Eliminar si existe, ignorar si no

Jerarquía de excepciones importantes

BaseException
├── SystemExit          — sys.exit()
├── KeyboardInterrupt   — Ctrl+C
└── Exception           — Todas las demás
    ├── ArithmeticError
    │   └── ZeroDivisionError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── OSError
    │   ├── FileNotFoundError
    │   ├── PermissionError
    │   └── ConnectionError
    ├── ValueError
    ├── TypeError
    ├── AttributeError
    ├── NameError
    └── RuntimeError

Resumen

El manejo robusto de errores es la diferencia entre código que falla misteriosamente en producción y código que falla con gracia y mensajes útiles. Captura las excepciones más específicas, usa finally para liberar recursos, y crea jerarquías de excepciones propias para aplicaciones complejas. En la próxima lección aprenderemos a organizar el código en módulos y paquetes.

Nunca uses except desnudo o except Exception sin relanzar
Un 'except:' sin tipo captura TODOS los errores, incluyendo KeyboardInterrupt y SystemExit. Esto puede ocultar bugs críticos. Captura siempre las excepciones más específicas que esperas.
El bloque else en try/except se ejecuta solo si NO hubo excepción
El bloque else es para el código que debe ejecutarse cuando el try tiene éxito. Esto separa el código 'feliz' del código de manejo de errores, haciendo el código más claro.
Usa finally para liberar recursos siempre
El bloque finally se ejecuta SIEMPRE — haya o no excepción. Úsalo para cerrar archivos, conexiones de base de datos, o liberar cualquier recurso que deba liberarse pase lo que pase.