21. Refactorizar manejo de errores y excepciones sin ocultar fallos

21.1 Objetivo del tema

Un error no debería desaparecer silenciosamente. Cuando el código captura cualquier excepción y devuelve un valor por defecto, el programa puede seguir funcionando con datos incorrectos y el fallo real queda oculto.

En este tema refactorizaremos manejo de errores en Python. Reemplazaremos except amplios por excepciones específicas, mensajes claros y pruebas que documenten los casos esperados.

Objetivo práctico: hacer que los errores sean visibles, comprensibles y testeables sin llenar el código de validaciones confusas.

21.2 Problema: errores ocultos

El siguiente patrón parece cómodo, pero es peligroso:

try:
    configuracion = cargar_configuracion("config.json")
except Exception:
    configuracion = {}

El código no distingue entre archivo inexistente, JSON mal formado, claves faltantes o un error de programación. Todos los fallos se convierten en un diccionario vacío.

21.3 Código inicial con except amplio

Crea el archivo src/configuracion.py:

import json


def cargar_configuracion(ruta):
    try:
        with open(ruta, encoding="utf-8") as archivo:
            datos = json.load(archivo)

        return {
            "host": datos["host"],
            "puerto": int(datos["puerto"]),
            "debug": bool(datos.get("debug", False)),
        }
    except Exception:
        return {
            "host": "localhost",
            "puerto": 8000,
            "debug": False,
        }

Si el archivo tiene una clave mal escrita, el programa arranca con valores por defecto y nadie sabe que la configuración estaba mal.

21.4 Prueba de comportamiento actual

Antes de modificar, documentamos el comportamiento correcto para una configuración válida:

from src.configuracion import cargar_configuracion


def test_carga_configuracion_valida(tmp_path):
    archivo = tmp_path / "config.json"
    archivo.write_text(
        '{"host": "api.local", "puerto": "9000", "debug": true}',
        encoding="utf-8",
    )

    configuracion = cargar_configuracion(archivo)

    assert configuracion == {
        "host": "api.local",
        "puerto": 9000,
        "debug": True,
    }
python -m pytest tests/test_configuracion.py

21.5 Decidir qué errores son esperados

No todos los errores deben tratarse igual. En este ejemplo podemos distinguir:

  • El archivo no existe.
  • El contenido no es JSON válido.
  • Faltan claves obligatorias.
  • El puerto no puede convertirse a número.

La refactorización empieza cuando nombramos esos casos. Lo que tiene nombre se puede probar y comunicar.

21.6 Crear excepciones del dominio

Podemos definir excepciones propias para que el código cliente entienda qué tipo de problema ocurrió:

class ErrorConfiguracion(Exception):
    pass


class ConfiguracionInvalida(ErrorConfiguracion):
    pass


class ConfiguracionNoEncontrada(ErrorConfiguracion):
    pass

Estas clases no agregan lógica todavía. Su valor está en expresar intención.

21.7 Separar lectura, parseo y validación

El siguiente paso es extraer funciones con responsabilidades claras:

def leer_archivo_texto(ruta):
    with open(ruta, encoding="utf-8") as archivo:
        return archivo.read()


def parsear_json(contenido):
    return json.loads(contenido)


def construir_configuracion(datos):
    return {
        "host": datos["host"],
        "puerto": int(datos["puerto"]),
        "debug": bool(datos.get("debug", False)),
    }

Aún falta mejorar los errores, pero el código ya permite probar el parseo y la construcción sin tocar archivos.

21.8 Reemplazar except amplio por casos específicos

Ahora la función pública captura solo errores esperados y los traduce a errores del dominio:

import json


def cargar_configuracion(ruta):
    try:
        contenido = leer_archivo_texto(ruta)
    except FileNotFoundError as error:
        raise ConfiguracionNoEncontrada(f"No existe el archivo: {ruta}") from error

    try:
        datos = parsear_json(contenido)
        return construir_configuracion(datos)
    except json.JSONDecodeError as error:
        raise ConfiguracionInvalida("El archivo no contiene JSON válido") from error
    except KeyError as error:
        raise ConfiguracionInvalida(f"Falta la clave obligatoria: {error.args[0]}") from error
    except ValueError as error:
        raise ConfiguracionInvalida("El puerto debe ser un número entero") from error

El uso de raise ... from error conserva la causa original. Esto ayuda al diagnóstico sin exponer detalles innecesarios al resto del programa.

21.9 Probar archivo inexistente

La prueba debe comprobar el tipo de error y el mensaje principal:

import pytest

from src.configuracion import ConfiguracionNoEncontrada, cargar_configuracion


def test_falla_si_archivo_no_existe(tmp_path):
    archivo = tmp_path / "no_existe.json"

    with pytest.raises(ConfiguracionNoEncontrada, match="No existe el archivo"):
        cargar_configuracion(archivo)
python -m pytest tests/test_configuracion.py

21.10 Probar JSON inválido

El JSON mal formado también tiene una respuesta explícita:

import pytest

from src.configuracion import ConfiguracionInvalida, cargar_configuracion


def test_falla_si_json_es_invalido(tmp_path):
    archivo = tmp_path / "config.json"
    archivo.write_text("{host: api.local}", encoding="utf-8")

    with pytest.raises(ConfiguracionInvalida, match="JSON válido"):
        cargar_configuracion(archivo)

21.11 Probar claves faltantes

Si falta una clave obligatoria, no conviene usar valores mágicos por defecto:

import pytest

from src.configuracion import ConfiguracionInvalida, cargar_configuracion


def test_falla_si_falta_host(tmp_path):
    archivo = tmp_path / "config.json"
    archivo.write_text('{"puerto": 9000}', encoding="utf-8")

    with pytest.raises(ConfiguracionInvalida, match="host"):
        cargar_configuracion(archivo)

21.12 Código refactorizado completo

import json


class ErrorConfiguracion(Exception):
    pass


class ConfiguracionInvalida(ErrorConfiguracion):
    pass


class ConfiguracionNoEncontrada(ErrorConfiguracion):
    pass


def leer_archivo_texto(ruta):
    with open(ruta, encoding="utf-8") as archivo:
        return archivo.read()


def parsear_json(contenido):
    return json.loads(contenido)


def construir_configuracion(datos):
    return {
        "host": datos["host"],
        "puerto": int(datos["puerto"]),
        "debug": bool(datos.get("debug", False)),
    }


def cargar_configuracion(ruta):
    try:
        contenido = leer_archivo_texto(ruta)
    except FileNotFoundError as error:
        raise ConfiguracionNoEncontrada(f"No existe el archivo: {ruta}") from error

    try:
        datos = parsear_json(contenido)
        return construir_configuracion(datos)
    except json.JSONDecodeError as error:
        raise ConfiguracionInvalida("El archivo no contiene JSON válido") from error
    except KeyError as error:
        raise ConfiguracionInvalida(f"Falta la clave obligatoria: {error.args[0]}") from error
    except ValueError as error:
        raise ConfiguracionInvalida("El puerto debe ser un número entero") from error

21.13 Evitar valores por defecto peligrosos

Los valores por defecto son correctos cuando forman parte explícita de la regla. En el ejemplo, debug puede ser opcional porque lo decidimos conscientemente.

En cambio, usar host="localhost" o puerto=8000 ante cualquier error oculta problemas de configuración. Un valor por defecto no debe funcionar como una alfombra para esconder fallos.

21.14 Capturar en el borde del programa

Una buena práctica es dejar que el núcleo lance errores claros y capturarlos en el borde de la aplicación:

def main():
    try:
        configuracion = cargar_configuracion("config.json")
    except ErrorConfiguracion as error:
        print(f"No se pudo iniciar la aplicación: {error}")
        return 1

    iniciar_aplicacion(configuracion)
    return 0

Así la regla de carga no decide cómo terminar el programa, mostrar mensajes o registrar eventos. Solo informa el problema.

21.15 Cuándo sí capturar una excepción

Capturar una excepción tiene sentido cuando el código puede hacer algo útil:

  • Traducir un error técnico a un error del dominio.
  • Agregar contexto al mensaje.
  • Reintentar una operación controlada.
  • Liberar recursos.
  • Mostrar un mensaje claro en el borde del programa.

Si no hay una acción concreta, probablemente conviene dejar que la excepción suba.

21.16 Cuándo evitar except Exception

except Exception captura demasiadas cosas: errores de datos, errores de infraestructura y errores de programación. Si se usa en el núcleo de la aplicación, puede ocultar fallos que deberían corregirse.

Puede ser aceptable en un borde externo, por ejemplo para registrar un error inesperado antes de terminar el proceso. Aun en ese caso, no debería convertir cualquier fallo en éxito.

21.17 Ejercicio propuesto

Refactoriza esta función para que no oculte errores:

def obtener_total_respuesta(respuesta):
    try:
        return float(respuesta["data"]["total"])
    except Exception:
        return 0.0

Objetivos:

  • Distinguir respuesta sin data.
  • Distinguir respuesta sin total.
  • Distinguir total no numérico.
  • Crear pruebas para cada caso.

21.18 Una posible solución

class RespuestaInvalida(Exception):
    pass


def obtener_total_respuesta(respuesta):
    try:
        data = respuesta["data"]
    except KeyError as error:
        raise RespuestaInvalida("La respuesta no contiene data") from error

    try:
        return float(data["total"])
    except KeyError as error:
        raise RespuestaInvalida("La respuesta no contiene total") from error
    except (TypeError, ValueError) as error:
        raise RespuestaInvalida("El total no es numérico") from error

Prueba posible:

import pytest

from src.respuestas import RespuestaInvalida, obtener_total_respuesta


def test_obtiene_total_numerico():
    respuesta = {"data": {"total": "125.50"}}

    assert obtener_total_respuesta(respuesta) == 125.50


def test_falla_si_no_hay_total():
    respuesta = {"data": {}}

    with pytest.raises(RespuestaInvalida, match="total"):
        obtener_total_respuesta(respuesta)
python -m pytest tests/test_respuestas.py

21.19 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Por qué un except amplio puede ocultar errores reales.
  • Cuándo conviene crear excepciones propias.
  • Cómo conservar la causa original con raise ... from error.
  • Qué diferencia hay entre error esperado y error de programación.
  • Por qué las excepciones se suelen capturar en los bordes del sistema.

21.20 Conclusión

En este tema reemplazamos errores ocultos por excepciones específicas y mensajes claros. El código dejó de convertir cualquier fallo en un valor por defecto y empezó a comunicar problemas reales.

En el próximo tema usaremos herramientas como pytest, coverage, ruff, black y mypy para guiar refactorizaciones con más seguridad.