Inicio / Artículos / Corrección de errores en el manejo de excepciones con Async/Await en código de producción de Node.js

Corrección de errores en el manejo de excepciones con Async/Await en código de producción de Node.js

Conoce cinco errores comunes al manejar excepciones con async/await en JavaScript y Node.js que causan fallos silenciosos y condiciones de carrera, además de soluciones concretas.

1766 palabras

Un sistema de pago implementado por un equipo el trimestre pasado terminó cobrando dos veces a los clientes por el mismo pedido, dos veces por semana, durante casi un mes antes de que surgiera el problema. La causa raíz no fue una pasarela de pago poco fiable, sino un bloque try/catch que rodeaba una llamada await y hacía exactamente lo para lo que estaba diseñado: ignorar el error y continuar, mientras que la lógica de reintentos en capas superiores asumía que una promesa resuelta significaba automáticamente éxito.

Nadie introduce un error así a propósito. Dado que la sintaxis async/await se parece al código síncrono habitual, los desarrolladores tienden naturalmente a razonar sobre ella de la misma manera. Pero el modelo subyacente de manejo de errores sigue estando basado en la rechazo de promesas, la programación de microtareas y reglas de cancelación que no se corresponden claramente con la intuición de try/catch. Incluso los ingenieros experimentados pueden verse afectados por esto, a menudo en código que ya había pasado las revisiones, porque estos errores solo surgen bajo condiciones de concurrencia o fallos parciales: precisamente los escenarios que las pruebas unitarias suelen omitir.

A continuación se presentan cinco errores recurrentes encontrados en bases de código JavaScript y Node.js 22/24 en entornos de producción, junto con soluciones que funcionan bien bajo tráfico real.

Error 1: Capturar errores y continuar en silencio

La forma incorrecta:

async function getUserProfile(userId) {
  try {
    const res = await fetch(`/api/users/${userId}`);
    return await res.json();
  } catch (err) {
    console.error('Failed to fetch user', err);
    return null;
  }
}
async function renderDashboard(userId) {
  const profile = await getUserProfile(userId);
  // profile.name throws here if fetch failed — but the stack trace
  // now points at renderDashboard, not at the network call that actually failed
  document.title = `${profile.name}'s Dashboard`;
}

Al envolver la llamada a fetch en un bloque catch, se convierte un fallo específico y rastreable (una respuesta 500 desde /api/users/42) en uno vago (profile is null). Para cuando surge realmente una excepción, como TypeError: Cannot read properties of null, ya ocurre lejos de la causa real, sin ninguna indicación de que hubo alguna solicitud de red involucrada. En entornos de producción, esa diferencia marca la diferencia entre una solución rápida en cinco minutos y una búsqueda exhaustiva en los registros que dura dos horas.

Uso correcto:

async function getUserProfile(userId) {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) {
    throw new Error(`Failed to fetch user ${userId}: ${res.status}`, {
      cause: { status: res.status, userId },
    });
  }
  return res.json();
}
async function renderDashboard(userId) {
  try {
    const profile = await getUserProfile(userId);
    document.title = `${profile.name}'s Dashboard`;
  } catch (err) {
    console.error('Dashboard render failed', err, err.cause);
    showErrorBanner('Could not load your profile. Please retry.');
  }
}

La función que menos entiende de las solicitudes de red —la función de obtención de datos— debería simplemente lanzar un error cuando algo sale mal. La decisión sobre qué significa exactamente un “fallido”, ya sea mostrar un banner, intentar la solicitud nuevamente o recurrir a datos en caché, corresponde a la función que cuente con una estrategia de recuperación. Utilizar Error.cause (introducido en ES2022 y disponible en todos los navegadores actuales además de Node.js 16.9 y versiones posteriores) permite conservar el contexto estructurado en lugar de reducirlo a un mensaje de cadena opaco.

Errore 2: Usar Promise.all cuando se necesita Promise.allSettled

La forma incorrecta:

async function loadDashboardData(userId) {
  const [profile, orders, recommendations] = await Promise.all([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId), // a third-party service with a 2% error rate
  ]);
  return { profile, orders, recommendations };
}

Promise.all está diseñado para fallar rápidamente: en cuanto una de las promesas se rechaza, toda la llamada se rechaza, descartando los resultados de las demás llamadas incluso si ya se resolvieron con éxito. Por lo tanto, si fetchRecommendations se agota el tiempo de espera, el usuario también pierde acceso a su perfil e historial de pedidos, a pesar de que ambas solicitudes se completaron sin errores. Este patrón es la causa de una gran parte de las quejas sobre “paneles de control inestables” presentadas contra código que, de lo contrario, sería perfectamente funcional.

Uso correcto:

async function loadDashboardData(userId) {
  const results = await Promise.allSettled([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId),
  ]);

const [profile, orders, recommendations] = results.map((r) =>
    r.status === 'fulfilled' ? r.value : null
  );
  results.forEach((r, i) => {
    if (r.status === 'rejected') {
      logNonFatal(['profile', 'orders', 'recommendations'][i], r.reason);
    }
  });
  return { profile, orders, recommendations };
}

Una regla práctica: utilice Promise.all solo cuando sea realmente necesario realizar todas las operaciones y un resultado parcial no tenga sentido, como tres escrituras que componen una única transacción atómica. Utilice Promise.allSettled siempre que las operaciones sean independientes entre sí y una interfaz de usuario parcialmente funcional sea mejor que ninguna — lo cual, en la práctica, aplica a la mayoría de los paneles de control, endpoints de agregación de datos y tareas de procesamiento por lotes.

Error 3: Ejecutar trabajos asíncronos sin manejar sus fallos

El patrón problemático:

function handleClick(event) {
  logAnalyticsEvent(event); // returns a promise, nobody awaits it
  updateUI();
}

Aquí, logAnalyticsEvent se declara como async, lo que significa que devuelve una promesa independientemente de si alguien la utiliza o no. Dado que nadie agrega un .catch, el rechazo de esa promesa no tiene a dónde ir. Si por casualidad el servicio de análisis resulta inaccesible, ese rechazo se convierte en un rechazo de promesa no manejado; algunos navegadores lo ignoran silenciosamente, pero en Node.js hace que el proceso se cierre completamente, ya que desde Node.js 15 los rechazos no manejados terminan automáticamente el proceso. Dentro de un manejador de solicitudes, esto significa que cada usuario que acceda a esa ruta recibirá un error 500, todo debido a una llamada en segundo plano por la que nadie estaba esperando nada.

Una versión más segura:

function handleClick(event) {
  void logAnalyticsEvent(event).catch((err) => {
    logNonFatal('analytics', err);
  });
  updateUI();
}

Al preceder la llamada con void, se indica tanto a los futuros mantenedores como a herramientas como @typescript-eslint/no-floating-promises que omitir el await aquí es intencional y no un descuido. Sin embargo, es el bloque .catch el que realmente hace la labor de evitar que un fallo en segundo plano se convierta en una interrupción visible. Si su código se ejecuta en Node.js, también vale la pena agregar un listener de nivel superior process.on('unhandledRejection', ...) como última línea de defensa, pero trátelo como una red de seguridad y no como su estrategia principal. Su función es registrar el problema y alertar a alguien, no compensar por un .catch que se olvidó escribir.

Errore 4: Permitir que las llamadas asíncronas compitan sin cancelar aquellas que fallan

El patrón problemático:

async function search(query) {
  const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
  renderResults(results);
}

searchInput.addEventListener('input', (e) => search(e.target.value));

Cada pulsación de tecla dispara una nueva solicitud de red. Dado que no se garantiza que las respuestas lleguen en el orden en que fueron enviadas, si la búsqueda de "reac" termina después que la de "react", los resultados obsoletos terminan sobrescribiendo a los correctos en la pantalla. Esto no es un caso excepcional: en una conexión lenta o con limitaciones, ocurre constantemente, y es una de las causas más frecuentes de los informes de errores en los que “el cuadro de búsqueda muestra resultados incorrectos” en cualquier aplicación con campo de búsqueda en tiempo real.

Una versión más segura:

let activeController = null;

async function search(query) {
  activeController?.abort();
  activeController = new AbortController();
  const { signal } = activeController;

try {
    const res = await fetch(`/api/search?q=${query}`, { signal });
    const results = await res.json();
    renderResults(results);
  } catch (err) {
    if (err.name !== 'AbortError') {
      logNonFatal('search', err);
    }
  }
}
searchInput.addEventListener('input', (e) => search(e.target.value));

AbortController, disponible de forma nativa en Node.js desde la versión 15 y soportado por todas las implementaciones modernas de fetch, convierte una competencia implícita en una explícita y controlada: la solicitud más reciente gana porque todas las anteriores son abortadas activamente, y no porque casualmente ganen una competencia por el tiempo. La misma idea se aplica cuando un componente se desmonta a mitad de una solicitud en React, Angular o Vue: se debe cancelar la solicitud durante el proceso de limpieza en lugar de simplemente esperar que su respuesta nunca llegue.

Error 5: Depender de finally en lugar de un manejo adecuado de errores

El patrón problemático:

async function processOrder(order) {
  let lock;
  try {
    lock = await acquireLock(order.id);
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    releaseLock(lock);
  }
}

A primera vista parece seguro, ya que finally siempre se ejecuta y debería liberar el bloqueo en todo caso. Pero si acquireLock lanza un error antes de asignar cualquier valor, lock permanece undefined para cuando se ejecuta finally. En ese momento, llamar a releaseLock(undefined) puede generar un segundo error no relacionado que oculta el fallo original, o bien no hacer nada —dependiendo de cómo esté implementado el mecanismo de bloqueo—, mientras que otro bloqueo completamente distinto se mantiene ocupado indefinidamente. finally solo garantiza que su bloque se ejecutará; no indica si la lógica de limpieza contenida en él es realmente válida para cada camino que podría haber llevado hasta allí.

Una versión más segura:

async function processOrder(order) {
  const lock = await acquireLock(order.id); // outside try — nothing to release yet
  try {
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    await releaseLock(lock);
  }
}

Solo las operaciones que dependen de que se haya adquirido efectivamente un bloqueo deben encontrarse dentro del bloque try que activa la limpieza. Se trata de un pequeño cambio estructural, pero permite separar la limpieza que se ejecuta precisamente cuando es necesaria de aquella que podría activarse en condiciones en las que solo dañaría el estado en lugar de corregirlo.

Resumen

Async/await nunca eliminó las dificultades de manejo de errores en JavaScript: simplemente las ocultó detrás de una sintaxis que parece código síncrono. Cinco hábitos abordan la mayoría de los problemas en entornos de producción que surgen por esta ocultación: lanzar tipos de error bien definidos en lugar de silenciarlos, utilizar Error.cause para conservar la causa original del fallo; usar Promise.allSettled cuando las operaciones subyacentes no dependen unas de otras; nunca dejar una promesa sin manejo con un .catch; cancelar tareas asíncronas obsoletas mediante AbortController en lugar de confiar en que el tiempo de respuesta de la red se resuelva por sí solo; y limitar los bloques finally a la limpieza del estado que realmente se haya adquirido.

Ninguna de estas prácticas es inusual o avanzada. Lo que representan es la brecha entre el código asíncrono que funciona bien en condiciones de red reales y el código asíncrono que solo funcionó durante una demostración.

Lecturas relacionadas

  • Ataques a la cadena de suministro de Npm: cómo funcionan y cómo defender Node.js — Explica cómo funcionan los ataques a la cadena de suministro de Npm, como las tomas de control de cuentas, el typosquatting y la confusión en las dependencias, además de pasos concretos para reforzar las instalaciones de Node.js.
  • Desarrollo local de Azure Functions: cómo solucionar los puntos de fallo más comunes — Aprenda cómo deben alinearse las Herramientas Básicas, los entornos de ejecución del lenguaje y Azurite, y obtenga soluciones prácticas para local.settings.json, los desencadenantes y los errores de depuración.
  • Elegir entre Promise.all, Promise.race y esperas secuenciales — Aprenda cuándo Promise.all() acelera las APIs de Node.js, por qué falla rápidamente ante cualquier rechazo, y un marco de toma de decisiones para elegir el patrón asíncrono adecuado.
  • Correlacionar logs en llamadas asíncronas con AsyncLocalStorage — Aprenda cómo Node.js AsyncLocalStorage sigue el contexto por solicitud, como requestId, a través de los límites de await sin tener que pasarlo manualmente por cada función.