Главная / Статьи / Исправление ошибок обработки исключений в моделях Async/Await в продакшн-коде Node.js

Исправление ошибок обработки исключений в моделях Async/Await в продакшн-коде Node.js

Узнайте о пяти распространённых ошибках обработки ошибок с использованием async/await в JavaScript и Node.js, которые приводят к скрытым сбоям и конкурентным ситуациям, а также о конкретных способах их устранения.

1766 слов

Система оформления заказов, внедренная одной командой в прошлом квартале, приводила к двойной оплате у клиентов за один и тот же заказ два раза в неделю в течение почти месяца, прежде чем возникла проблема. Коренной причиной был не ненадежный платежный шлюз, а блок try/catch, окружающий вызов await, который выполнял именно то, для чего был написан — подавлять ошибки и продолжать работу, — в то время как логика повторных попыток на нескольких уровнях выше предполагала, что автоматическое решение проблемы с обещанием означает успех.

Никто не вносит такой баг намеренно. Поскольку синтаксис async/await выглядит как обычный синхронный код, разработчики естественным образом рассматривают его таким же образом. Однако основная модель обработки ошибок по-прежнему опирается на отклонение обещаний, планирование микозадач и правила отмены, которые не соответствуют интуитивному пониманию конструкций try/catch. Даже опытные инженеры могут столкнуться с этой проблемой, часто в коде, который уже прошел проверку, поскольку такие баги проявляются только при одновременном выполнении операций или частичных сбоях — именно в тех сценариях, которые единичные тесты обычно игнорируют.

Ниже приведены пять часто встречающихся ошибок в производственных кодовых базах на JavaScript и Node.js 22/24 вместе с решениями, которые эффективно работают при реальной нагрузке.

Ошибка 1: Поймать ошибку и молча продолжить работу

Неправильный способ:

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`;
}

Использование общего блока catch вокруг операции fetch превращает конкретную, легко отслеживаемую ошибку (ответ кода 500 с адреса /api/users/42) в неопределенную (profile is null). К тому моменту, когда появляется реальная ошибка — например, TypeError: Cannot read properties of null — это происходит далеко от истинной причины, и нет никаких указаний на то, что ранее была отправлена сетевая запрос. В производственной среде именно эта разница определяет, потребуется ли быстрое решение за пять минут или двухчасовой поиск в логах.

Правильное использование:

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.');
  }
}

Функция, которая наименее понимает сетевой запрос — функция загрузки данных — должна просто выбрасывать исключение при возникновении ошибки. Решение о том, что именно означает «ошибка»: отображение баннера, повторная попытка запроса или использование кэшированных данных, принадлежит той функции, у которой есть стратегия восстановления. Использование Error.cause (введенного в ES2022 и доступного во всех современных браузерах, а также в Node.js 16.9 и новее версиях) позволяет сохранить структурированный контекст вместо того, чтобы преобразовывать его в нечитаемое строковое сообщение.

Ошибка 2: Использование Promise.all вместо Promise.allSettled

Неправильный способ:

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 преднамеренно работает по принципу быстрого сбоя: как только хотя бы один из обещаний отклоняется, вся операция также отклоняется, что приводит к потере результатов остальных операций, даже если те уже были успешно выполнены. Таким образом, если fetchRecommendations завершается с истечением времени, пользователь теряет доступ к своему профилю и истории заказов, несмотря на то, что оба запроса фактически были выполнены без ошибок. Именно этот подход лежит в основе большинства жалоб на «нестабильный панель управления», поступающих по поводу кода, который в остальном совершенно корректен.

Правильное использование:

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 };
}

Полезное правило: использовать Promise.all только тогда, когда каждая операция действительно необходима и частичный результат будет бессмысленным, например при трех записях, составляющих одну атомарную транзакцию. Использовать Promise.allSettled в тех случаях, когда операции независимы друг от друга и частично работающий интерфейс лучше, чем пустой — что на практике относится к большинству панелей управления, конечных точек агрегации данных и задач пакетной обработки.

Ошибка 3: запуск асинхронных операций без обработки их сбоев

Проблемная паттерн:

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

Здесь функция logAnalyticsEvent объявлена как async, что означает, что она возвращает обещание независимо от того, интересуется ли им кто-либо. Поскольку никто не привязал обработчик .catch, отклонение этого обещания не имеет куда пойти. Если сервис аналитики оказывается недоступным, отклонение превращается в непроцессированное отклонение обещания — некоторые браузеры молча игнорируют это, но в Node.js это приводит к сбою процесса, поскольку с версии Node.js 15 непроцессированные отклонения по умолчанию прерывают работу процесса. Внутри обработчика запросов это означает, что каждый пользователь, обращающийся к этому маршруту, получает ошибку 500, и всё это из-за фонового вызова, на который никто даже не ждал ответа.

Более безопасная версия:

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

Добавление префикса void перед вызовом сообщает будущим сотрудникам, занимающимся обслуживанием кода, а также инструментам вроде @typescript-eslint/no-floating-promises, что пропуск оператора await здесь сделан намеренно, а не из-за ошибки. Однако именно блок .catch выполняет основную функцию по предотвращению того, чтобы фоновая ошибка привела к сбою в основном потоке выполнения. Если ваш код работает под Node.js, также стоит добавить слушатель на верхнем уровне process.on('unhandledRejection', ...) в качестве последней линии защиты — но рассматривайте его как запасной вариант, а не основную стратегию. Его задача — записать информацию о проблеме и оповестить кого-либо, а не компенсировать отсутствие написанного блока .catch.

Ошибка 4: Позволение асинхронным вызовам соревноваться друг с другом без отмены тех, которые терпят неудачу

Проблемная паттерн:

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));

Каждое нажатие клавиши запускает новый сетевой запрос. Поскольку ответы не гарантированно приходят в том порядке, в котором были отправлены, если поиск по "reac" завершается позже, чем поиск по "react", устаревшие результаты могут перезаписать правильные на экране. Это не какой-то редкий крайний случай — при медленном или ограниченном соединении это происходит постоянно, и это одна из наиболее частых причин сообщений об ошибках вроде «в поле поиска отображаются неверные результаты» в любом приложении с функцией поиска в реальном времени.

Более безопасная версия:

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, встроенный в Node.js с версии 15 и поддерживаемый всеми современными реализациями fetch, превращает скрытую гонку в явную, контролируемую: побеждает самый последний запрос, поскольку все предыдущие активно прерываются, а не потому, что им случайно удается выиграть соревнование по времени. Та же идея применяется, когда компонент демонтируется на этапе выполнения запроса в React, Angular или Vue — запрос отменяется во время очистки, а не просто в надежде на то, что его ответ так и не поступит.

Ошибка 5: Опора на finally вместо надлежащей обработки ошибок

Проблемная практика:

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

На первый взгляд это кажется безопасным, поскольку блок finally всегда выполняется и должен всегда освобождать блокировку. Однако если сам метод acquireLock выбрасывает исключение до того, как что-либо будет присвоено, переменная lock остается значением undefined к моменту выполнения блока finally. Вызов releaseLock(undefined) в этот момент либо приводит к второму, не связанному с первым ошибке, который скрывает исходную проблему, либо ничего не делает — в зависимости от реализации механизма блокировки — при этом совершенно другая блокировка остается активной бесконечно. Блок finally гарантирует лишь выполнение своего кода; он ничего не говорит о том, действительно ли логика очистки внутри него корректна для всех возможных сценариев, приведших к его выполнению.

Более безопасная версия:

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);
  }
}

Только те операции, которые зависят от факта получения блокировки, должны находиться внутри блока try, запускающего процедуру очистки. Это небольшое структурное изменение, но оно отделяет очистку, которая выполняется ровно тогда, когда это необходимо, от очистки, которая может быть вызвана в ситуациях, когда она лишь повредит состояние вместо того, чтобы его исправить.

Итоги

Async/await никогда не устранил проблем с обработкой ошибок в JavaScript — он лишь скрыл их за синтаксисом, похожим на синхронный код. Пять привычек помогают решить большинство проблем в производственной среде, возникающих из-за этого: использовать четко определенные типы ошибок вместо их подавления, применять Error.cause для сохранения первоначальной причины сбоя; использовать Promise.allSettled, когда основные операции не зависят друг от друга; никогда не оставлять обещания без обработки ошибок с помощью .catch; отменять устаревшие асинхронные операции с использованием AbortController, вместо того чтобы полагаться на самостоятельное решение сетевых задержек; а также ограничивать использование блоков finally только очисткой состояния, которое действительно было получено.

Ни один из этих подходов не является необычным или сложным. Они демонстрируют разницу между асинхронным кодом, способным функционировать в реальных сетевых условиях, и асинхронным кодом, который работает только во время демонстрации.

Связанные статьи

  • Атаки на цепочку поставок npm: как они работают и как защитить Node.js — Объясняется, как функционируют атаки на цепочку поставок npm, такие как захват аккаунтов, тайпосквоттинг и путаница с зависимостями, а также приводятся конкретные шаги по усилению безопасности установок Node.js.
  • Разработка локальных функций Azure: устранение распространённых проблем — Рассматривается, как должны согласовываться основные инструменты, языковые среды выполнения и Azurite, а также приводятся практические решения для файлов local.settings.json, триггеров и ошибок отладки.
  • Выбор между Promise.all, Promise.race и последовательными ожиданиями — Узнайте, когда Promise.all() ускоряет работу API Node.js, почему он быстро терпит неудачу при любом отклонении, а также как выбрать подходящий асинхронный паттерн.
  • Связывание логов между асинхронными вызовами с помощью AsyncLocalStorage — Узнайте, как Node.js AsyncLocalStorage отслеживает контекст каждого запроса, такой как requestId, между операциями await, без необходимости вручную передавать его через каждую функцию.