Раздел 5 · Асинхронность

Ошибки, async stack trace и graceful shutdown

middle-senior~35 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Ошибки, async stack trace и graceful shutdown

Этот урок собирает в одно место всё, что обычно раскидано по соседним темам: где живёт стек async-функции, какие виды ошибок бывают, как ловить unhandledRejection, что делать с race conditions, и как корректно завершать процесс по SIGTERM.

Большинство примеров с async stack trace опираются на репозиторий HowProgrammingWorks/StackTrace. Полезно держать его рядом, особенно файлы с 1-sync.js по 9-unhandled.js.

После урока ты сможешь спокойно отлаживать ошибку, прилетевшую сквозь три async-границы, и точно знать, что делать с pending запросами при рестарте.

1. Что V8 хранит в стеке

Каждый кадр стека, это синхронный вызов. Когда функция вызвала throw, движок собирает текущий стек и кладёт в error.stack.

const a = () => b();
const b = () => c();
const c = () => { throw new Error('boom'); };

try { a(); } catch (e) { console.log(e.stack); }

Стек прозрачный:

Error: boom
    at c (file.ts:3:21)
    at b (file.ts:2:14)
    at a (file.ts:1:14)
    at <anonymous>

Проблема начинается, когда между a и b, async-граница: await, setTimeout, setImmediate, любой переход через event loop. Каждый await, это, по сути, выход из стека и возврат позже из микротаска. Старый стек теряется.

2. Async stack trace в V8

С Node 12+ V8 включает --async-stack-traces по умолчанию. Это zero-cost механизм: V8 запоминает стек на момент await, потом склеивает его с новым стеком при throw.

const fetchUser = async (id: number) => {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('HTTP ' + res.status);
  return res.json();
};

const handler = async () => {
  return fetchUser(7);
};

await handler();

Если fetchUser упал, стек выглядит так:

Error: HTTP 500
    at fetchUser (file.ts:3:11)
    at async handler (file.ts:8:10)
    at async <anonymous>

Это работает только для async/await. Если в коде остался ручной then(...) без await, кадр потеряется.

3. return vs return await, ещё раз про стек

Урок 03-promise-and-async-await уже разбирал тонкость. Повторим суть, потому что для отладки она ключевая.

const ax = async () => {
  return failing();
};

const bx = async () => {
  return await failing();
};

bx запоминает стек на await. ax, нет: возврат промиса не идёт через await, V8 не склеивает кадр. Когда из глубины поднимется ошибка, в bx будет видно bx как кадр, а в ax, нет.

ESLint правило @typescript-eslint/return-await рекомендует ставить await всегда в try/catch и почти всегда вне его. Это бесплатно улучшает диагностику.

4. Виды ошибок

Полезная классификация, особенно когда писать обработчики.

ОсьВариантыЧто значит
Семантикаoperational, programmerслучилось во внешнем мире vs баг в коде
Восстановимостьrecoverable, fatalможно продолжить vs нужно завершить
Точка отловаlocal, ascend, globalcatch здесь, наверху, или process-level

operational, это сетевая ошибка, файл не нашёлся, валидация не прошла. Ожидаемый исход, должен быть в API типов.

programmer, это TypeError: Cannot read 'x' of undefined. Бага в коде, которую не предвидели. Ожидать её в типах нельзя.

Стартовая позиция:

  • operational, ловить локально, переводить в типизированный Result или throw с осмысленным сообщением.
  • programmer, не ловить, давать упасть в unhandledException. Дальше graceful shutdown.

5. Error.cause: оборачивание без потери

С Node 16.9 появился Error.cause, стандартный способ обернуть одну ошибку в другую без потери диагностики:

const fetchOrFail = async (url: string) => {
  try {
    return await fetch(url);
  } catch (err) {
    throw new Error(`fetch ${url} failed`, { cause: err });
  }
}

error.cause содержит исходную ошибку. error.stack склеивает оба стека. В логах будет видно и “fetch failed”, и под ним “TypeError: fetch failed: Connection refused”.

Это убирает нужду в монструозных wrappedError.originalError. Стандарт есть, и он работает.

Обычно cause ходит в паре с Result: ожидаемый сбой ловят на месте, отдают значением, а причину кладут внутрь. Ниже редактор с такой обёрткой, тесты сверяют cause по ссылке. Гоняются прямо в браузере, ставить ничего не нужно.

6. unhandledRejection

Если промис rejected, и никто не повесил catch, движок кричит. В Node, это событие process.on('unhandledRejection', handler). В браузере, window.addEventListener('unhandledrejection', handler).

process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
  logger.error({ reason, promise }, 'unhandled rejection');
});

process.on('uncaughtException', (err: Error) => {
  logger.error({ err }, 'uncaught exception');
  void shutdown(1);
});

Стартовая позиция:

  • unhandledRejection, логировать и держать процесс живым (по дефолту в Node 15+ процесс падает, это можно поменять через --unhandled-rejections=warn).
  • uncaughtException, логировать и завершать процесс. Состояние повреждено, продолжать опасно.

В обоих случаях идти на graceful shutdown, не process.exit(1) сразу.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.