Ir al contenido principal

Manejo de errores con catch

Introducción

Cuando trabajamos con promesas en JavaScript, es fundamental saber cómo responder cuando las cosas no salen según lo esperado. El método catch() es una herramienta esencial que nos permite detectar, gestionar y recuperarnos de errores que pueden ocurrir durante la ejecución de operaciones asíncronas. Un manejo de errores adecuado no sólo mejora la experiencia del usuario al evitar que la aplicación se bloquee, sino que también facilita la depuración y el mantenimiento del código.

En este artículo, exploraremos en profundidad cómo utilizar el método catch() para gestionar errores en nuestras promesas, cómo implementar estrategias efectivas de manejo de errores y cómo construir aplicaciones más robustas y resistentes a fallos.

El método catch() en promesas

El método catch() es parte fundamental de la API de Promesas y nos permite capturar errores que ocurren durante la ejecución de una promesa. Técnicamente, catch(onRejected) es un alias para then(undefined, onRejected), lo que significa que se especializa en manejar únicamente los casos de rechazo.

Veamos la sintaxis básica:

miPromesa
  .then(resultado => {
    // Código que se ejecuta cuando la promesa se resuelve correctamente
    console.log('Operación exitosa:', resultado);
  })
  .catch(error => {
    // Código que se ejecuta cuando la promesa es rechazada
    console.error('Ocurrió un error:', error);
  });

La función que pasamos a catch() recibe como parámetro el motivo del rechazo, que generalmente es un objeto Error o cualquier valor que se haya pasado al método reject() de la promesa.

Captura de errores en diferentes niveles

Una característica poderosa del método catch() es que puede capturar errores que ocurren en cualquier punto de la cadena de promesas anterior a él. Esto significa que podemos tener múltiples operaciones then() y un único catch() al final para manejar cualquier error.

obtenerDatos()
  .then(datos => procesarDatos(datos))
  .then(datosFormateados => mostrarResultados(datosFormateados))
  .catch(error => {
    // Este catch captura errores de cualquiera de las promesas anteriores
    console.error('Error en la cadena de operaciones:', error);
  });

También podemos tener múltiples bloques catch() en diferentes puntos de la cadena para manejar errores específicos:

obtenerDatos()
  .then(datos => {
    if (!datos) {
      throw new Error('No se recibieron datos');
    }
    return procesarDatos(datos);
  })
  .catch(error => {
    // Este catch sólo maneja errores hasta este punto
    console.error('Error al obtener o validar datos:', error);
    // Devolvemos datos por defecto para continuar la cadena
    return { tipo: 'datos_por_defecto', contenido: [] };
  })
  .then(datosFormateados => mostrarResultados(datosFormateados))
  .catch(error => {
    // Este catch maneja errores en mostrarResultados
    console.error('Error al mostrar resultados:', error);
  });

Propagación de errores en cadenas

Cuando trabajamos con cadenas de promesas, los errores se propagan automáticamente hacia abajo hasta encontrar un catch() que los maneje. Si un then() devuelve una promesa rechazada, esa promesa rechazada se propagará a través de la cadena.

Promise.resolve(1)
  .then(valor => {
    console.log('Primer then:', valor); // 1
    // Lanzamos un error
    throw new Error('Error en el primer then');
    // Este return nunca se ejecuta
    return valor + 1;
  })
  .then(valor => {
    // Este bloque nunca se ejecuta porque el error se propaga
    console.log('Segundo then:', valor);
    return valor + 1;
  })
  .then(valor => {
    // Este bloque tampoco se ejecuta
    console.log('Tercer then:', valor);
    return valor + 1;
  })
  .catch(error => {
    // Aquí se captura el error
    console.error('Error capturado:', error.message); // "Error en el primer then"
    return 'Recuperado'; // Devolvemos un valor para continuar la cadena
  })
  .then(valor => {
    // Este bloque sí se ejecuta porque el catch devolvió un valor
    console.log('Después del catch:', valor); // "Recuperado"
  });

En este ejemplo, una vez que se lanza el error en el primer then(), los siguientes bloques then() se omiten hasta que el error es capturado por el catch(). Después del catch(), la cadena continúa normalmente.

Recuperación de errores

Una de las grandes ventajas del método catch() es que nos permite recuperarnos de errores y seguir con la ejecución de la cadena. Podemos hacer esto devolviendo un valor desde el manejador de catch():

function obtenerDatosDeAPI() {
  return new Promise((resolve, reject) => {
    const exito = Math.random() > 0.5; // Simulamos éxito o fracaso aleatorio
    
    setTimeout(() => {
      if (exito) {
        resolve({ id: 123, nombre: 'Producto ejemplo', precio: 29.99 });
      } else {
        reject(new Error('No se pudo conectar con el servidor'));
      }
    }, 1000);
  });
}

obtenerDatosDeAPI()
  .then(datos => {
    console.log('Datos recibidos:', datos);
    return datos;
  })
  .catch(error => {
    console.error('Error al obtener datos:', error.message);
    console.log('Usando datos de respaldo...');
    // Devolvemos datos de respaldo para continuar
    return { id: 0, nombre: 'Producto de respaldo', precio: 0 };
  })
  .then(datos => {
    // Este código se ejecuta tanto con los datos reales como con los de respaldo
    console.log(`Mostrando producto: ${datos.nombre} - ${datos.precio}€`);
  });

En este ejemplo, si la promesa obtenerDatosDeAPI() es rechazada, el catch() proporciona datos de respaldo, permitiendo que la aplicación continúe funcionando en lugar de fallar por completo.

Lanzamiento de nuevos errores

Dentro de un bloque catch(), podemos decidir lanzar un nuevo error para indicar que no pudimos manejar adecuadamente el error original o para transformar el error en uno más específico:

obtenerUsuario(id)
  .then(usuario => {
    if (!usuario) {
      throw new Error('Usuario no encontrado');
    }
    return obtenerPermisos(usuario.id);
  })
  .catch(error => {
    if (error.message === 'Usuario no encontrado') {
      // Registramos el error y lanzamos uno nuevo más específico
      console.warn('Advertencia:', error.message);
      throw new Error('Error de acceso: el usuario solicitado no existe');
    } else if (error.name === 'NetworkError') {
      // Intentamos una estrategia alternativa
      console.warn('Problema de red, usando caché local');
      return obtenerPermisosDesdeCache(id);
    } else {
      // Para otros errores, los propagamos hacia abajo
      throw error;
    }
  })
  .then(permisos => {
    // Procesar permisos
  })
  .catch(error => {
    // Este catch capturará tanto el nuevo error lanzado como cualquier otro
    console.error('Error final:', error.message);
  });

Este patrón es útil para implementar una estrategia de manejo de errores por niveles, donde diferentes partes del código pueden manejar diferentes tipos de errores.

Errores silenciosos y cómo evitarlos

Un error común al trabajar con promesas es olvidar añadir un manejador catch(), lo que puede resultar en "errores silenciosos" - errores que ocurren pero no son capturados ni manejados adecuadamente:

// ❌ MAL: Sin gestión de errores
function cargarDatos() {
  obtenerDatosDeAPI()
    .then(datos => {
      procesarDatos(datos);
    });
  // Si ocurre un error, nunca lo sabremos
}

// ✅ BIEN: Con gestión de errores
function cargarDatos() {
  obtenerDatosDeAPI()
    .then(datos => {
      procesarDatos(datos);
    })
    .catch(error => {
      console.error('Error al cargar datos:', error);
      mostrarMensajeDeError('No se pudieron cargar los datos');
    });
}

Para evitar los errores silenciosos:

  1. Siempre añade un catch() al final de tus cadenas de promesas
  2. Utiliza un manejador global de errores de promesas no capturadas
// Manejador global para promesas no capturadas (solo para depuración)
window.addEventListener('unhandledrejection', event => {
  console.error('Error de promesa no capturado:', event.reason);
});

Patrones robustos de manejo de errores

Patrón 1: Clasificación de errores

Crear clases de error personalizadas para diferentes tipos de errores facilita su manejo:

// Definimos clases de error personalizadas
class ErrorDeRed extends Error {
  constructor(mensaje) {
    super(mensaje);
    this.name = 'ErrorDeRed';
  }
}

class ErrorDeAutorizacion extends Error {
  constructor(mensaje) {
    super(mensaje);
    this.name = 'ErrorDeAutorizacion';
  }
}

class ErrorDeDatos extends Error {
  constructor(mensaje) {
    super(mensaje);
    this.name = 'ErrorDeDatos';
  }
}

// Función que lanza diferentes tipos de errores
function obtenerRecurso(id) {
  return new Promise((resolve, reject) => {
    const aleatorio = Math.random();
    
    setTimeout(() => {
      if (aleatorio < 0.3) {
        reject(new ErrorDeRed('No se pudo conectar al servidor'));
      } else if (aleatorio < 0.6) {
        reject(new ErrorDeAutorizacion('No tienes permisos para este recurso'));
      } else if (aleatorio < 0.9) {
        reject(new ErrorDeDatos('El recurso está corrupto o tiene formato inválido'));
      } else {
        resolve({id: id, nombre: 'Recurso ' + id});
      }
    }, 1000);
  });
}

// Manejo específico según el tipo de error
obtenerRecurso(123)
  .then(recurso => {
    console.log('Recurso obtenido:', recurso);
  })
  .catch(error => {
    if (error instanceof ErrorDeRed) {
      console.error('Problema de conexión:', error.message);
      mostrarMensajeReconexion();
    } else if (error instanceof ErrorDeAutorizacion) {
      console.error('Problema de permisos:', error.message);
      redirigirALogin();
    } else if (error instanceof ErrorDeDatos) {
      console.error('Problema con los datos:', error.message);
      intentarRepararDatos();
    } else {
      console.error('Error desconocido:', error);
      mostrarErrorGenerico();
    }
  });

Patrón 2: Reintentos automáticos

Para operaciones que pueden fallar temporalmente, como solicitudes de red:

function obtenerDatosConReintentos(url, maxReintentos = 3, retrasoBase = 1000) {
  return new Promise((resolve, reject) => {
    function intentar(intento) {
      fetch(url)
        .then(respuesta => {
          if (!respuesta.ok) {
            throw new Error(`HTTP error! status: ${respuesta.status}`);
          }
          return respuesta.json();
        })
        .then(datos => {
          resolve(datos); // Éxito, resolvemos la promesa
        })
        .catch(error => {
          console.warn(`Intento ${intento} fallido:`, error.message);
          
          if (intento < maxReintentos) {
            // Calculamos un retraso con backoff exponencial
            const retraso = retrasoBase * Math.pow(2, intento - 1);
            console.log(`Reintentando en ${retraso}ms...`);
            
            setTimeout(() => {
              intentar(intento + 1);
            }, retraso);
          } else {
            // Si superamos el número máximo de reintentos, rechazamos la promesa
            reject(new Error(`Fallaron los ${maxReintentos} intentos. Último error: ${error.message}`));
          }
        });
    }
    
    // Comenzamos con el primer intento
    intentar(1);
  });
}

// Uso
obtenerDatosConReintentos('https://api.ejemplo.com/datos', 3)
  .then(datos => {
    console.log('Datos obtenidos exitosamente:', datos);
  })
  .catch(error => {
    console.error('Error final después de reintentos:', error.message);
    mostrarErrorAlUsuario('No se pudieron cargar los datos. Por favor, inténtalo más tarde.');
  });

Patrón 3: Envoltura de catch para depuración

Para facilitar la depuración sin perder la funcionalidad de recuperación:

function catchConLog(fn) {
  return function(...args) {
    return fn(...args)
      .catch(error => {
        console.error('Error en función:', fn.name, error);
        throw error; // Re-lanzamos el error para mantener la cadena de rechazo
      });
  };
}

// Aplicamos la envoltura a nuestras funciones
const obtenerUsuarioSeguro = catchConLog(obtenerUsuario);
const obtenerPermisosSeguro = catchConLog(obtenerPermisos);

// Uso
obtenerUsuarioSeguro(123)
  .then(usuario => obtenerPermisosSeguro(usuario.id))
  .then(permisos => {
    console.log('Operación completada con éxito');
  })
  .catch(error => {
    // Este catch seguirá recibiendo el error, pero ya habremos visto los logs detallados
    console.error('Error general:', error.message);
  });

Mejores prácticas con promesas

  1. Sé específico con los errores:

    • Utiliza mensajes de error descriptivos
    • Incluye información relevante (ID, valores, etc.)
    • Considera usar clases de error personalizadas
  2. Maneja los errores lo más cerca posible de su origen:

    • Captura y maneja errores donde tengas el contexto para hacerlo
    • Transforma errores específicos en errores más generales cuando sea apropiado
  3. Evita catch vacíos:

    // ❌ MAL: Catch vacío que oculta errores
    promesa.catch(() => {});
    
    // ✅ BIEN: Al menos registra el error
    promesa.catch(error => {
      console.error('Error en operación:', error);
    });
    
  4. Encadena correctamente:

    • Asegúrate de devolver valores o promesas desde los manejadores then() y catch()
    • Mantén la cadena fluida evitando anidaciones innecesarias
  5. Maneja tanto los casos de éxito como los de error:

    promesa
      .then(resultado => {
        // Manejar éxito
      })
      .catch(error => {
        // Manejar error
      })
      .finally(() => {
        // Código que se ejecuta en ambos casos
        ocultarIndicadorCarga();
      });
    
  6. Proporciona valores por defecto sensatos:

    obtenerConfiguracion()
      .catch(error => {
        console.warn('No se pudo cargar la configuración:', error);
        return CONFIGURACION_POR_DEFECTO;
      })
      .then(config => {
        iniciarAplicacion(config);
      });
    
  7. Considera el contexto del usuario:

    • Transforma errores técnicos en mensajes amigables para el usuario
    • Ofrece acciones concretas que el usuario pueda realizar para resolver el problema

Resumen

El método catch() es una herramienta fundamental en el manejo de errores con promesas. Nos permite:

  • Capturar errores en diferentes niveles de una cadena de promesas
  • Implementar estrategias de recuperación para mantener la aplicación funcionando
  • Transformar errores técnicos en mensajes comprensibles para el usuario
  • Construir aplicaciones más robustas y resistentes a fallos

Un buen manejo de errores es lo que distingue a una aplicación profesional de una amateur. Mediante el uso adecuado de catch() y la implementación de patrones robustos de manejo de errores, podemos crear experiencias que funcionan correctamente incluso cuando las cosas no salen según lo planeado.

Recuerda que los errores son inevitables, pero una aplicación que maneja los errores con elegancia proporciona una experiencia de usuario mucho mejor que una que simplemente falla.