14. Manejo de errores: excepciones silenciadas, genéricas o mal ubicadas

14.1 Objetivo del tema

El manejo de errores es parte central de la calidad de código. Una excepción mal capturada puede ocultar fallas, devolver resultados incorrectos o hacer que el programa continúe en un estado inválido.

En este tema veremos smells frecuentes relacionados con excepciones: capturas demasiado genéricas, errores silenciados, try ubicados en lugares incorrectos y mensajes poco útiles. Trabajaremos con ejemplos Python y prácticas seguras.

Objetivo práctico: reconocer malos manejos de errores en Python y reemplazarlos por validaciones, excepciones específicas y mensajes claros.

14.2 Por qué importa manejar bien los errores

Un error bien manejado ayuda a saber qué pasó, dónde pasó y qué puede hacer el programa a continuación. Un error mal manejado esconde información o transforma una falla clara en un comportamiento extraño.

El objetivo no es llenar el código de try y except. El objetivo es decidir qué errores podemos resolver localmente y cuáles debemos dejar subir para que otra capa los maneje.

14.3 Smell: excepción silenciada

Silenciar una excepción consiste en capturarla y no hacer nada, o devolver un valor que oculta el problema.

def obtener_precio(producto):
    try:
        return float(producto["precio"])
    except Exception:
        pass

Esta función puede devolver None sin explicación. Quien la usa no sabe si faltó el precio, si el valor era inválido o si ocurrió otro error.

14.4 Mejora: capturar errores específicos

Conviene capturar solo las excepciones que esperamos y sabemos manejar.

def obtener_precio(producto):
    try:
        return float(producto["precio"])
    except KeyError:
        raise ValueError("El producto no tiene precio")
    except ValueError:
        raise ValueError("El precio del producto no es numérico")

Ahora el error conserva información útil. No se oculta; se transforma en un mensaje más cercano al dominio.

14.5 Smell: except Exception demasiado amplio

Capturar Exception puede ocultar errores inesperados.

def calcular_total(productos):
    try:
        return sum(producto["precio"] * producto["cantidad"] for producto in productos)
    except Exception:
        return 0

Si hay un error en una clave, un tipo incorrecto o una regla mal escrita, la función devuelve 0. Ese resultado puede parecer válido y contaminar cálculos posteriores.

14.6 Validar antes de calcular

A veces una validación explícita es mejor que capturar cualquier excepción.

def validar_producto(producto):
    if "precio" not in producto:
        raise ValueError("Falta el precio del producto")
    if "cantidad" not in producto:
        raise ValueError("Falta la cantidad del producto")
    if producto["cantidad"] <= 0:
        raise ValueError("La cantidad debe ser positiva")


def calcular_total(productos):
    total = 0
    for producto in productos:
        validar_producto(producto)
        total += producto["precio"] * producto["cantidad"]
    return total

La validación hace visible la regla y produce errores claros.

14.7 Smell: try demasiado grande

Un bloque try muy grande dificulta saber qué operación puede fallar.

def cargar_total(ruta):
    try:
        archivo = open(ruta, encoding="utf-8")
        lineas = archivo.readlines()
        valores = [float(linea) for linea in lineas]
        total = sum(valores)
        archivo.close()
        return total
    except ValueError:
        return 0

La excepción ValueError ocurre al convertir texto a número, no al abrir el archivo. El try debería cubrir la zona precisa.

14.8 Ubicar el try cerca de la operación riesgosa

def convertir_linea_a_float(linea):
    try:
        return float(linea)
    except ValueError as error:
        raise ValueError(f"Valor numérico inválido: {linea.strip()}") from error


def cargar_total(ruta):
    with open(ruta, encoding="utf-8") as archivo:
        valores = [convertir_linea_a_float(linea) for linea in archivo]
    return sum(valores)

El manejo de error queda donde ocurre la conversión. Además, with se encarga de cerrar el archivo correctamente.

14.9 Encadenar excepciones con from

Cuando transformas una excepción en otra, usar from conserva la causa original.

def obtener_cantidad(producto):
    try:
        return int(producto["cantidad"])
    except KeyError as error:
        raise ValueError("Falta la cantidad del producto") from error
    except ValueError as error:
        raise ValueError("La cantidad debe ser un número entero") from error

Esto ayuda a depurar porque no se pierde la excepción original.

14.10 Errores de dominio

En algunos casos conviene crear una excepción propia para expresar un problema del dominio.

class ProductoInvalidoError(ValueError):
    pass


def validar_producto(producto):
    if producto["cantidad"] <= 0:
        raise ProductoInvalidoError("La cantidad debe ser positiva")

No hace falta crear clases de excepción para todo. Úsalas cuando ayuden a diferenciar errores importantes para la aplicación.

14.11 No usar excepciones para flujo normal

Las excepciones son útiles para situaciones excepcionales. No conviene usarlas como reemplazo de una condición simple.

def obtener_descuento(cliente):
    try:
        return {"vip": 0.15, "regular": 0.05}[cliente]
    except KeyError:
        return 0

En este caso, dict.get expresa mejor la intención:

DESCUENTOS = {
    "vip": 0.15,
    "regular": 0.05,
}


def obtener_descuento(cliente):
    return DESCUENTOS.get(cliente, 0)

14.12 Registrar sin ocultar

A veces necesitamos registrar el error y volver a lanzarlo. Eso es distinto de silenciarlo.

import logging

logger = logging.getLogger(__name__)


def procesar_archivo(ruta):
    try:
        return cargar_total(ruta)
    except OSError:
        logger.exception("No se pudo procesar el archivo %s", ruta)
        raise

logger.exception registra el traceback dentro de un bloque except. Luego raise vuelve a lanzar la excepción original.

14.13 Aplicación sobre ventas_demo

Supón que en ventas_demo agregamos una validación de productos:

def validar_producto(producto):
    if "precio" not in producto:
        raise ValueError("Falta el precio")
    if "cantidad" not in producto:
        raise ValueError("Falta la cantidad")
    if producto["precio"] < 0:
        raise ValueError("El precio no puede ser negativo")
    if producto["cantidad"] <= 0:
        raise ValueError("La cantidad debe ser positiva")

Luego el cálculo puede usar esa validación:

def calcular_subtotal(productos):
    subtotal = 0
    for producto in productos:
        validar_producto(producto)
        subtotal += producto["precio"] * producto["cantidad"]
    return subtotal

14.14 Probar errores esperados

Si una función debe lanzar una excepción ante datos inválidos, conviene probarlo.

import pytest

from ventas import calcular_subtotal


def test_calcular_subtotal_falla_si_falta_precio():
    productos = [{"cantidad": 2}]

    with pytest.raises(ValueError, match="Falta el precio"):
        calcular_subtotal(productos)

La prueba documenta el comportamiento esperado ante un error.

14.15 Mensajes de error útiles

Un buen mensaje de error debe ayudar a corregir el problema. Evita mensajes genéricos como "error" o "datos inválidos" si puedes ser más específico.

raise ValueError("La cantidad debe ser positiva")

Si el contexto importa, agrégalo con cuidado:

raise ValueError(f"La cantidad debe ser positiva: {producto['cantidad']}")

14.16 Ejercicio guiado

Mejora el manejo de errores de esta función:

def calcular_promedio(datos):
    try:
        total = 0
        for item in datos:
            total += item["valor"]
        return total / len(datos)
    except Exception:
        return 0

Problemas:

  • Captura cualquier excepción.
  • Devuelve 0 para errores muy distintos.
  • No distingue lista vacía de datos mal formados.
  • Oculta claves faltantes o valores no numéricos.

14.17 Una posible solución

def obtener_valor(item):
    if "valor" not in item:
        raise ValueError("Falta la clave valor")
    return float(item["valor"])


def calcular_promedio(datos):
    if not datos:
        raise ValueError("No se puede calcular promedio sin datos")

    valores = [obtener_valor(item) for item in datos]
    return sum(valores) / len(valores)

Ahora los errores son explícitos. Quien llama a la función puede decidir si muestra un mensaje, registra el problema o detiene el proceso.

14.18 Ejercicio propuesto

En ventas_demo, agrega validación para productos y realiza estas tareas:

  • Detecta productos sin precio o sin cantidad.
  • Rechaza precios negativos.
  • Rechaza cantidades menores o iguales a cero si esa es la regla actual.
  • Agrega pruebas con pytest.raises.
  • Ejecuta Ruff y las pruebas.
python -m ruff check src tests
python -m pytest

14.19 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Reconocer excepciones silenciadas.
  • Evitar except Exception cuando no corresponde.
  • Capturar errores específicos.
  • Ubicar el try cerca de la operación riesgosa.
  • Usar raise ... from error cuando transformas excepciones.
  • Probar errores esperados con pytest.raises.
  • Escribir mensajes de error claros y útiles.

14.20 Conclusión

En este tema vimos que el manejo de errores también puede producir code smells. Silenciar excepciones, capturar errores demasiado generales o ubicar mal los try vuelve el sistema más difícil de depurar y mantener.

En el próximo tema estudiaremos complejidad ciclomática: cómo medirla y reducirla en funciones Python.