async/await и обработка ошибок

Про async/await спрашивают всех, но интересные ответы начинаются на вопросе «а где здесь можно потерять ошибку».

async-функция всегда возвращает промис. await не блокирует поток: он приостанавливает выполнение конкретной функции и отдаёт управление event loop до тех пор, пока промис не разрешится.

Что проверяет интервьюер

Прежде всего — понимание, что async/await это синтаксический сахар над промисами, а не «магия синхронности». Дальше идёт практика: как ловить ошибки, что происходит с промисом без await, что случится с процессом при необработанном отказе и почему forEach ломается на асинхронных колбэках.

Базовая обработка ошибок

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

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.name = 'HttpError';
    this.status = status;
  }
}

function parseAge(raw) {
  const n = Number(raw);
  if (!Number.isInteger(n) || n < 0) {
    throw new HttpError(400, 'некорректный возраст: ' + raw);
  }
  return n;
}

for (const raw of ['30', '-1', 'abc']) {
  try {
    console.log('ок:', parseAge(raw));
  } catch (e) {
    if (e instanceof HttpError) console.log(e.status, e.message);
    else throw e;                    // чужие ошибки не глотаем
  }
}

Вывод:

ок: 30
400 некорректный возраст: -1
400 некорректный возраст: abc

Ключевая деталь, за которую цепляются на собеседовании: в catch нужно различать ошибки, а не глотать всё подряд. Блок catch (e) {} без разбора превращает баги в тишину.

Где теряются ошибки

async function saveOrder(order) {
  await db.insert(order);
}

async function handler(req, res) {
  saveOrder(req.body);         // ЛОВУШКА: нет await
  res.json({ ok: true });      // ответили «успех», хотя запись могла упасть
}

Промис без await и без .catch() — самая частая причина «молчаливых» багов в проде. Если он отклонится, начиная с Node 15 процесс по умолчанию завершается с ошибкой unhandledRejection. Раньше выводилось только предупреждение, и на собеседовании любят уточнять, как изменилось поведение.

Если запуск в фоне нужен намеренно, это надо писать явно:

// намеренно fire-and-forget, но с явной обработкой
void saveOrder(req.body).catch((err) => logger.error({ err }, 'saveOrder failed'));

// страховочная сетка процесса (логировать и выходить, а не «жить дальше»)
process.on('unhandledRejection', (err) => {
  logger.fatal({ err }, 'unhandled rejection');
  process.exit(1);
});

Ловушка forEach

// НЕ работает: forEach не ждёт промисы
items.forEach(async (item) => {
  await save(item);
});
console.log('всё сохранено');    // печатается сразу, ничего ещё не сохранено

// последовательно — когда важен порядок или нельзя нагружать БД
for (const item of items) {
  await save(item);
}

// параллельно — когда операции независимы
await Promise.all(items.map((item) => save(item)));

forEach просто вызывает колбэк и выбрасывает возвращённый промис. Ошибки внутри такого колбэка тоже никто не поймает. Правильных вариантов два — for...of с await для последовательной обработки и Promise.all для параллельной.

await в try, finally и возврат значения

async function withConnection(fn) {
  const conn = await pool.connect();
  try {
    return await fn(conn);       // именно await, а не просто return
  } finally {
    conn.release();              // выполнится и при ошибке, и при успехе
  }
}

Здесь важен нюанс, который на интервью считается признаком опыта: return await внутри try нужен, чтобы ошибка из fn попала в текущий try/finally, а соединение освободилось до того, как промис уйдёт наверх. Без await функция вернёт промис слишком рано, и finally отработает раньше, чем завершится работа.

Типичные ошибки кандидатов

  • Говорят, что await «останавливает поток» — он приостанавливает только текущую функцию.
  • Забывают await и удивляются, что ошибки не ловятся, а ответ уходит раньше записи в БД.
  • Пишут async-колбэк в forEach и считают, что цикл дождётся операций.
  • Не знают, что необработанный reject с Node 15 роняет процесс.
  • Ловят всё в catch и возвращают 500 без логирования — ошибка исчезает.

Как ответить кратко

async-функция всегда возвращает промис, а await приостанавливает только её саму, отдавая управление event loop. Reject превращается в исключение, поэтому ловится обычным try/catch. Главные грабли — забытый await: ответ уходит раньше операции, ошибка не ловится, и с Node 15 необработанный reject завершает процесс. В forEach асинхронные колбэки не ждутся: для последовательной обработки нужен for...of, для параллельной — Promise.all.

Проверьте себя
1. Что вернёт вызов async-функции, внутри которой стоит return 42?
AЧисло 42
BПромис, разрешающийся значением 42
Cundefined
DОшибку, если нет await
2. Что произойдёт в Node.js 18, если промис отклонится, а обработчика ошибки нет?
AНичего, ошибка молча проигнорируется
BВ консоль выведется предупреждение, процесс продолжит работу
CПроцесс завершится с unhandledRejection
DОшибка попадёт в ближайший try/catch выше по стеку
3. Почему items.forEach(async (item) => { await save(item); }) не дожидается сохранения?
AПотому что forEach игнорирует возвращённый колбэком промис
BПотому что await внутри стрелочной функции не работает
CПотому что forEach выполняется в отдельном потоке
DПотому что save обязана быть синхронной