Ошибки, 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, global | catch здесь, наверху, или 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.