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:
- El bloque
try
contiene nuestro código asíncrono - 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 bloquecatch
- El bloque
catch
recibe el objeto error que contiene información sobre lo que salió mal - 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:
- Claridad visual: El código sigue un flujo más natural, de arriba hacia abajo, sin bifurcaciones en la lógica.
- Alcance de variables: Las variables declaradas en el bloque
try
están disponibles en el bloquecatch
. - Uniformidad: El mismo mecanismo de manejo de errores que usamos para código síncrono.
- 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.