Ir al contenido principal

Manejo de errores con try/catch

Introducción

El manejo efectivo de errores es una parte fundamental de cualquier aplicación robusta. En el contexto de la programación asíncrona con async/await, JavaScript nos proporciona una forma elegante de capturar y gestionar errores mediante los bloques try/catch. Esta sintaxis familiar, que ya existía en JavaScript para código síncrono, adquiere un nuevo nivel de utilidad al combinarse con operaciones asíncronas.

Lo que hace especialmente valioso el uso de try/catch con async/await es que nos permite manejar errores asíncronos con la misma estructura que utilizaríamos para errores síncronos, unificando nuestro enfoque de manejo de errores y haciendo que nuestro código sea más consistente, legible y mantenible. En este artículo, exploraremos cómo implementar esta técnica eficazmente, sus ventajas sobre otros métodos y los patrones más útiles para controlar errores en operaciones asíncronas.

Bloques try/catch con async/await

Los bloques try/catch son estructuras de control que nos permiten "intentar" ejecutar un bloque de código y "capturar" cualquier excepción que pueda ocurrir durante esa ejecución. Cuando trabajamos con async/await, podemos envolver nuestras operaciones asíncronas en un bloque try/catch para manejar posibles errores:

async function obtenerDatosUsuario(id) {
  try {
    // Intentamos ejecutar código asíncrono
    const respuesta = await fetch(`https://api.ejemplo.com/usuarios/${id}`);
    
    if (!respuesta.ok) {
      throw new Error(`Error HTTP: ${respuesta.status}`);
    }
    
    const datosUsuario = await respuesta.json();
    return datosUsuario;
  } catch (error) {
    // Capturamos cualquier error que ocurra en el bloque try
    console.error('Error al obtener datos del usuario:', error);
    // Podemos hacer algo con el error, como mostrar un mensaje al usuario
    mostrarMensajeError('No pudimos obtener tus datos. Por favor, intenta más tarde.');
    // También podemos retornar un valor por defecto
    return { id: id, nombre: 'Usuario desconocido', error: true };
  }
}

En este ejemplo:

  1. El bloque try contiene nuestro código asíncrono
  2. Si ocurre algún error durante la ejecución del bloque try (ya sea un error de red, un error HTTP, un error al analizar JSON, o un error explícito que lancemos), la ejecución salta inmediatamente al bloque catch
  3. El bloque catch recibe el objeto error que contiene información sobre lo que salió mal
  4. Dentro del catch, podemos registrar el error, mostrar un mensaje al usuario, intentar una operación alternativa, o devolver un valor por defecto

Captura de errores asíncronos

Lo que hace especial a try/catch con async/await es su capacidad para capturar errores provenientes de promesas rechazadas. Cuando usamos await con una promesa, si esa promesa es rechazada, await convierte ese rechazo en una excepción que puede ser capturada con try/catch.

Esto nos permite manejar errores asíncronos de forma muy similar a como manejaríamos errores síncronos:

// Función que devuelve una promesa que se rechazará
function operacionPropensaAFallos() {
  return new Promise((resolve, reject) => {
    const exito = Math.random() > 0.5;
    setTimeout(() => {
      if (exito) {
        resolve('Operación completada con éxito');
      } else {
        reject(new Error('La operación ha fallado'));
      }
    }, 1000);
  });
}

// Función asíncrona que maneja los errores con try/catch
async function ejecutarOperacionSegura() {
  try {
    console.log('Iniciando operación...');
    const resultado = await operacionPropensaAFallos();
    console.log('Éxito:', resultado);
    return resultado;
  } catch (error) {
    console.error('Error capturado:', error.message);
    return 'Valor de respaldo tras error';
  } finally {
    console.log('Operación finalizada (con o sin éxito)');
  }
}

// Llamamos a la función
ejecutarOperacionSegura().then(resultado => {
  console.log('Resultado final:', resultado);
});

Lo interesante de este ejemplo es que manejamos el error asíncrono (el rechazo de la promesa) exactamente igual que manejaríamos un error síncrono, con la misma estructura try/catch, lo que hace que nuestro código sea más consistente y fácil de entender.

Bloque finally

Además de los bloques try y catch, también podemos usar un bloque finally, que se ejecutará independientemente de si ocurrió un error o no:

async function procesarArchivo() {
  let recurso = null;
  
  try {
    recurso = await abrirRecurso();
    const datos = await leerDatos(recurso);
    await procesarDatos(datos);
    return 'Procesamiento completado';
  } catch (error) {
    console.error('Error durante el procesamiento:', error);
    throw error; // Relanzamos el error después de registrarlo
  } finally {
    // Este código se ejecuta siempre, haya error o no
    if (recurso) {
      await cerrarRecurso(recurso);
      console.log('Recurso cerrado correctamente');
    }
  }
}

El bloque finally es especialmente útil para tareas de limpieza o liberación de recursos, ya que garantiza que cierto código se ejecutará independientemente del resultado de la operación.

Comparación con .catch() en promesas

Antes de async/await, manejábamos errores en promesas utilizando el método .catch(). Veamos la diferencia entre ambos enfoques:

Con promesas y .catch():

function obtenerDatosConPromesas(id) {
  return fetch(`https://api.ejemplo.com/usuarios/${id}`)
    .then(respuesta => {
      if (!respuesta.ok) {
        throw new Error(`Error HTTP: ${respuesta.status}`);
      }
      return respuesta.json();
    })
    .then(datos => {
      return procesarDatos(datos);
    })
    .catch(error => {
      console.error('Error en la operación:', error);
      return { error: true, mensaje: error.message };
    });
}

Con async/await y try/catch:

async function obtenerDatosConAsync(id) {
  try {
    const respuesta = await fetch(`https://api.ejemplo.com/usuarios/${id}`);
    
    if (!respuesta.ok) {
      throw new Error(`Error HTTP: ${respuesta.status}`);
    }
    
    const datos = await respuesta.json();
    return await procesarDatos(datos);
  } catch (error) {
    console.error('Error en la operación:', error);
    return { error: true, mensaje: error.message };
  }
}

Las principales ventajas del enfoque con try/catch son:

  1. Claridad visual: El código sigue un flujo más natural, de arriba hacia abajo, sin bifurcaciones en la lógica.
  2. Alcance de variables: Las variables declaradas en el bloque try están disponibles en el bloque catch.
  3. Uniformidad: El mismo mecanismo de manejo de errores que usamos para código síncrono.
  4. Granularidad: Podemos envolver bloques específicos de código asíncrono para un manejo de errores más preciso.

Manejo de múltiples operaciones asíncronas

Uno de los escenarios más comunes es necesitar manejar errores en múltiples operaciones asíncronas. Veamos varias estrategias:

1. Envolver todo en un único try/catch

async function operacionCompleta() {
  try {
    const datosA = await operacionA();
    const datosB = await operacionB(datosA);
    const datosC = await operacionC(datosB);
    return datosC;
  } catch (error) {
    // Este catch captura errores de cualquiera de las tres operaciones
    console.error('Error en la operación completa:', error);
    return null;
  }
}

Este enfoque es simple pero no permite distinguir en qué operación ocurrió el error ni manejar cada caso de manera específica.

2. Try/catch anidados para un manejo más específico

async function operacionConManejoEspecifico() {
  try {
    const datosA = await operacionA();
    
    try {
      const datosB = await operacionB(datosA);
      
      try {
        const datosC = await operacionC(datosB);
        return datosC;
      } catch (errorC) {
        console.error('Error en operación C:', errorC);
        // Manejo específico para errores de C
        return { tipo: 'resultadoParcial', datos: datosB };
      }
    } catch (errorB) {
      console.error('Error en operación B:', errorB);
      // Manejo específico para errores de B
      return { tipo: 'datosBasicos', datos: datosA };
    }
  } catch (errorA) {
    console.error('Error en operación A:', errorA);
    // Manejo específico para errores de A
    return { tipo: 'error', mensaje: 'No se pudieron obtener datos básicos' };
  }
}

Este enfoque permite un manejo más detallado pero puede llevar a un código muy anidado y difícil de mantener.

3. Refactorizar en funciones más pequeñas

Una mejor estrategia es dividir las operaciones en funciones más pequeñas, cada una con su propio manejo de errores:

async function obtenerDatosA() {
  try {
    return await operacionA();
  } catch (error) {
    console.error('Error en operación A:', error);
    throw new Error(`Error en datos básicos: ${error.message}`);
  }
}

async function obtenerDatosB(datosA) {
  try {
    return await operacionB(datosA);
  } catch (error) {
    console.error('Error en operación B:', error);
    // Devolvemos datos parciales en lugar de fallar completamente
    return { parcial: true, datos: datosA };
  }
}

async function obtenerDatosC(datosB) {
  try {
    return await operacionC(datosB);
  } catch (error) {
    console.error('Error en operación C:', error);
    // Devolvemos datos B si C falla
    return { completado: false, datos: datosB };
  }
}

async function operacionCompleta() {
  try {
    const datosA = await obtenerDatosA();
    const datosB = await obtenerDatosB(datosA);
    const datosC = await obtenerDatosC(datosB);
    return datosC;
  } catch (error) {
    // Este catch solo capturará errores no manejados en las subfunciones
    console.error('Error crítico en la operación:', error);
    return { error: true, mensaje: error.message };
  }
}

Este enfoque mantiene el código más modular, facilita la reutilización y permite un manejo de errores específico para cada etapa sin caer en excesiva anidación.

Propagación de errores

En ocasiones, queremos capturar un error para registrarlo o realizar alguna acción, pero luego queremos que el error siga propagándose hacia arriba en la cadena de llamadas. Para esto, podemos usar throw dentro del bloque catch:

async function validarDatos(datos) {
  try {
    if (!datos.id) {
      throw new Error('Datos sin ID válido');
    }
    return await procesarDatos(datos);
  } catch (error) {
    // Registramos el error
    console.error('Error en validación:', error);
    
    // Y lo propagamos hacia arriba
    throw error;
  }
}

async function guardarDatos(datos) {
  try {
    // Si validarDatos falla, el error se propagará a este catch
    const datosValidados = await validarDatos(datos);
    return await guardarEnBaseDeDatos(datosValidados);
  } catch (error) {
    console.error('No se pudieron guardar los datos:', error);
    // Informamos al usuario
    mostrarMensajeError('Error al guardar: ' + error.message);
    // Y también propagamos el error si es necesario
    throw new Error(`Error de guardado: ${error.message}`);
  }
}

También podemos transformar el error antes de propagarlo:

async function obtenerUsuario(id) {
  try {
    const respuesta = await fetch(`/api/usuarios/${id}`);
    if (!respuesta.ok) {
      throw new Error(`Error HTTP: ${respuesta.status}`);
    }
    return await respuesta.json();
  } catch (error) {
    // Transformamos el error a uno más informativo
    if (error.message.includes('404')) {
      throw new Error(`Usuario con ID ${id} no encontrado`);
    } else if (error.message.includes('403')) {
      throw new Error('No tiene permisos para acceder a este usuario');
    } else {
      throw new Error(`Error al obtener usuario: ${error.message}`);
    }
  }
}

Errores personalizados

Para mejorar nuestro manejo de errores, podemos crear clases de errores personalizados que nos ayuden a distinguir entre diferentes tipos de errores:

// Definimos clases de errores personalizados
class ErrorDeBD extends Error {
  constructor(mensaje, codigoError) {
    super(mensaje);
    this.name = 'ErrorDeBD';
    this.codigoError = codigoError;
  }
}

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

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

// Uso de los errores personalizados
async function registrarUsuario(datosUsuario) {
  try {
    // Validamos los datos
    if (!datosUsuario.email) {
      throw new ErrorDeValidacion('Email es requerido', 'email');
    }
    
    // Verificamos si el usuario ya existe
    const respuesta = await fetch(`/api/verificar-email?email=${encodeURIComponent(datosUsuario.email)}`);
    
    if (!respuesta.ok) {
      throw new ErrorDeAPI('Error al verificar email', respuesta.status);
    }
    
    const { existe } = await respuesta.json();
    
    if (existe) {
      throw new ErrorDeValidacion('Este email ya está registrado', 'email');
    }
    
    // Guardamos el usuario
    const resultadoGuardado = await guardarUsuarioEnBD(datosUsuario);
    
    if (!resultadoGuardado.exito) {
      throw new ErrorDeBD('Error al guardar en la base de datos', resultadoGuardado.codigo);
    }
    
    return resultadoGuardado.usuario;
  } catch (error) {
    // Podemos manejar cada tipo de error de manera diferente
    if (error instanceof ErrorDeValidacion) {
      mostrarErrorEnCampo(error.campo, error.message);
    } else if (error instanceof ErrorDeAPI) {
      if (error.estado === 503) {
        mostrarMensaje('El servicio no está disponible temporalmente. Intente más tarde.');
      } else {
        mostrarMensaje('Error de comunicación con el servidor.');
      }
    } else if (error instanceof ErrorDeBD) {
      enviarErrorAlSistemaDeMonitoreo(error);
      mostrarMensaje('Error interno. El equipo técnico ha sido notificado.');
    } else {
      console.error('Error no manejado:', error);
      mostrarMensaje('Ocurrió un error inesperado.');
    }
    
    // Relanzamos para que la función que llamó a esta pueda manejarlo también
    throw error;
  }
}

Los errores personalizados nos permiten incluir información adicional relevante para cada tipo de error y facilitan el manejo específico según la naturaleza del problema.

Patrones robustos de manejo de errores

Finalmente, veamos algunos patrones avanzados para crear un sistema de manejo de errores robusto:

1. Wrapper para manejo consistente

Podemos crear una función de utilidad que envuelva nuestras operaciones asíncronas para un manejo de errores consistente:

async function manejarAsync(promesaOFuncion, opcionesDeError = {}) {
  try {
    // Si recibimos una función, la ejecutamos; si es una promesa, la esperamos
    const resultado = typeof promesaOFuncion === 'function' 
      ? await promesaOFuncion() 
      : await promesaOFuncion;
      
    // Devolvemos un objeto con formato estándar para éxito
    return { 
      exito: true, 
      datos: resultado 
    };
  } catch (error) {
    // Registramos el error si es necesario
    if (opcionesDeError.registrar !== false) {
      console.error('Error en operación asíncrona:', error);
    }
    
    // Podemos transformar el error según las opciones
    const mensajeError = opcionesDeError.mensaje || error.message;
    
    // Ejecutamos callback de error si existe
    if (opcionesDeError.onError) {
      opcionesDeError.onError(error);
    }
    
    // Devolvemos un objeto con formato estándar para error
    return { 
      exito: false, 
      error: error,
      mensaje: mensajeError
    };
  }
}

// Uso del wrapper
async function obtenerYProcesarDatos() {
  // Obtenemos datos con manejo de errores estándar
  const resultado = await manejarAsync(obtenerDatos);
  
  if (!resultado.exito) {
    mostrarMensajeError(resultado.mensaje);
    return null;
  }
  
  // Procesamos los datos también con manejo de errores
  const resultadoProcesado = await manejarAsync(
    () => procesarDatos(resultado.datos),
    { 
      mensaje: 'No se pudieron procesar los datos correctamente',
      onError: (e) => registrarErrorEnServidor(e) 
    }
  );
  
  return resultadoProcesado.exito ? resultadoProcesado.datos : null;
}

2. Centralización del manejo de errores

Otro patrón útil es centralizar el manejo de errores comunes:

// Servicio centralizado de manejo de errores
const servicioErrores = {
  // Manejadores específicos para tipos de errores
  manejadores: {
    'ErrorDeRed': (error) => {
      mostrarMensajeError('Problemas de conexión. Verifica tu internet.');
      return { manejado: true, reintentable: true };
    },
    'ErrorDeAutorizacion': (error) => {
      redirigirALogin();
      return { manejado: true, reintentable: false };
    },
    'ErrorDeAPI': (error) => {
      if (error.estado === 429) {
        mostrarMensajeError('Demasiadas solicitudes. Intenta más tarde.');
        return { manejado: true, reintentable: true, reintentarEn: 5000 };
      }
      return { manejado: false };
    }
  },
  
  // Método para manejar cualquier error
  manejar(error) {
    console.error('Error capturado:', error);
    
    // Identificamos el tipo de error
    const tipoError = error.name || 'Error';
    
    // Si tenemos un manejador específico, lo usamos
    if (this.manejadores[tipoError]) {
      const resultado = this.manejadores[tipoError](error);
      if (resultado.manejado) {
        return resultado;
      }
    }
    
    // Manejo genérico para errores sin manejador específico
    mostrarMensajeError('Ha ocurrido un error: ' + error.message);
    return { manejado: true, reintentable: false };
  }
};

// Uso del servicio centralizado
async function operacionConManejoGlobal() {
  try {
    const datos = await obtenerDatos();
    return procesarDatos(datos);
  } catch (error) {
    const resultado = servicioErrores.manejar(error);
    
    // Podemos implementar reintentos automáticos
    if (resultado.reintentable) {
      console.log('Reintentando operación...');
      if (resultado.reintentarEn) {
        await new Promise(resolve => setTimeout(resolve, resultado.reintentarEn));
      }
      return operacionConManejoGlobal(); // Llamada recursiva
    }
    
    return null;
  }
}

Este enfoque centralizado permite mantener una lógica de manejo de errores consistente en toda la aplicación y facilita la implementación de estrategias como reintentos automáticos.

Resumen

El manejo de errores con try/catch en funciones asíncronas nos proporciona una forma poderosa y elegante de gestionar los problemas que pueden surgir en nuestras operaciones asíncronas. A diferencia del método .catch() de las promesas, el enfoque try/catch nos permite:

  • Manejar errores de múltiples operaciones asíncronas en un solo bloque
  • Utilizar la misma sintaxis para errores síncronos y asíncronos
  • Aprovechar el bloque finally para ejecutar código de limpieza
  • Implementar un manejo de errores más granular y específico
  • Desarrollar patrones robustos como errores personalizados y manejo centralizado

Al dominar estas técnicas, podemos crear aplicaciones más resistentes que manejan los errores de forma elegante, proporcionan feedback útil a los usuarios y mantienen un comportamiento predecible incluso cuando las cosas no salen según lo planeado.

Recuerda que un buen sistema de manejo de errores no solo se trata de evitar que la aplicación se bloquee, sino también de proporcionar una experiencia de usuario fluida y profesional incluso cuando ocurren problemas inesperados.