Skip to main content

Як обробляти async/await в обробниках маршрутів Express.js?

Async/await в обробниках маршрутів Express - Express не перехоплює відхилення Promise з async-обробників автоматично, тому необроблені помилки повністю оминають middleware для обробки помилок.

Теорія

TL;DR

  • Express створили до появи async/await. Його система обробки помилок синхронна.
  • Коли async-обробник відхиляється, відхилення стає в чергу мікрозадач. Express вже вийшов зі свого try/catch на той момент.
  • Необроблені відхилення не потрапляють до error middleware - вони або кладуть процес, або генерують попередження.
  • Рішення: загортай async-обробники у функцію, яка викликає .catch(next).
  • Правило вибору: один asyncHandler wrapper покриває всі async-маршрути і middleware.

Швидкий приклад

js
// ❌ ЗЛАМАНО - відхилення ніколи не доходить до error handler app.get('/users', async (req, res) => { const users = await User.findAll(); // падає? Додаток крашиться. res.json(users); }); // ✅ ПРАВИЛЬНО - відхилення передається в error middleware const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); app.get('/users', asyncHandler(async (req, res) => { const users = await User.findAll(); res.json(users); })); // Error middleware тепер отримує всі async-помилки app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); });

Wrapper перехоплює будь-яке відхилення з async-функції і викликає next(err). Express спрямовує його до error middleware. Більше нічого не змінюється.

Чому Express не ловить async-помилки

Система middleware Express синхронна в своїй основі. Коли ти await-ш всередині обробника, Node.js повертає Promise. Якщо той Promise відхиляється, відхилення стає в чергу мікрозадач. Express вже вийшов зі свого синхронного try/catch на той момент. Він просто не бачить цього відхилення.

Це не баг. Express вийшов у 2010 році, за сім років до async/await. Архітектура була правильною для свого часу.

Wrapper asyncHandler

Патерн виглядає просто, але робить реальну роботу:

js
const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };

Promise.resolve() загортає повернуте значення обробника в Promise, навіть якщо воно не є Promise. Це покриває змішаний sync/async код. .catch(next) приєднує обробник відхилень до ланцюжка, тому будь-яке відхилення викличе next(err). Express пропускає весь звичайний middleware і йде прямо до чотириаргументного error handler-а.

Можна також використовувати try/catch напряму, коли потрібно трансформувати помилку перед передачею:

js
app.get('/users', async (req, res, next) => { try { const users = await User.findAll(); res.json(users); } catch (err) { next(new DatabaseError('Query failed', err)); // кастомна форма помилки } });

Використовуй wrapper для типових випадків. Використовуй try/catch + next(), коли потрібно змінити форму помилки.

Коли що використовувати

  • asyncHandler wrapper: кожен async-обробник маршруту і async middleware
  • try/catch + next(err): коли потрібно залогувати, трансформувати або доповнити помилку перед передачею
  • Ні те, ні інше: синхронні обробники не потребують жодного з них
  • Уникай: .catch(), який мовчки ковтає помилки - це приховує баги

Альтернатива: express-async-errors

Якщо не хочеш вручну загортати кожен обробник, пакет express-async-errors патчить Express зсередини:

js
// npm install express-async-errors require('express-async-errors'); // імпортуй один раз на початку app.js // Тепер працює без wrapper-а app.get('/users', async (req, res) => { const users = await User.findAll(); res.json(users); });

Зручно для наявних кодових баз. Але це monkey-patch, і частина команд уникає такого підходу в продакшені. Обидва варіанти працюють.

Express 5 (нативна підтримка async)

Express 5 підтримує async-відхилення нативно. Без wrapper-а, без патчів:

bash
npm install express@next
js
// Express 5 - async помилки передаються автоматично app.get('/users', async (req, res) => { const users = await User.findAll(); res.json(users); });

Express 5 ще у стані RC на 2024 рік, але вже достатньо стабільний для нових проектів. Наявні проекти на Express 4 краще залишити з wrapper-підходом.

Типові помилки

Помилка 1: Не загортати async middleware

js
// ❌ НЕПРАВИЛЬНО - помилки middleware оминають error handler app.use(async (req, res, next) => { req.user = await User.findById(req.headers.authorization); next(); }); // ✅ ПРАВИЛЬНО - middleware теж потребує wrapper-а app.use(asyncHandler(async (req, res, next) => { req.user = await User.findById(req.headers.authorization); next(); }));

Розробники часто загортають обробники маршрутів, але забувають про middleware. Та сама проблема, те саме рішення.

Помилка 2: Promise без await (fire-and-forget)

js
// ❌ НЕПРАВИЛЬНО - відхилення email не перехоплюється asyncHandler app.get('/notify', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); sendEmail(user.email); // запущено без await - відхилення випадає res.json(user); })); // ✅ ПРАВИЛЬНО - обробляй fire-and-forget явно app.get('/notify', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); sendEmail(user.email).catch(err => { logger.error('Email failed', err); // логуй, не падай }); res.json(user); }));

asyncHandler перехоплює тільки ті Promise, які awaited або повернуті. Promise, який запущено без await, поза його зоною відповідальності.

Помилка 3: Розраховувати на нову версію Node.js

Навіть у Node 20 або 22, Express 4 не перехоплює async-відхилення. Node змінив поведінку для необроблених відхилень (крах проти попередження), але система middleware Express не змінилась. Wrapper все ще потрібен.

Помилка 4: Виклик next() після надсилання відповіді

js
// ❌ НЕПРАВИЛЬНО - відповідь вже надіслана, next() нічого не зробить app.get('/users', asyncHandler(async (req, res, next) => { const users = await User.findAll(); res.json(users); next(); // немає ефекту })); // ✅ ПРАВИЛЬНО app.get('/users', asyncHandler(async (req, res) => { const users = await User.findAll(); res.json(users); }));

Після res.json() відповідь надіслана. Не викликай next() після неї.

Де це зустрічається

  • Express.js: кожен async-обробник і middleware потребує wrapper-а або try/catch
  • Fastify: схожа проблема, потрібне явне оброблення помилок для async-обробників
  • Koa: побудований навколо Promise з самого початку, async-помилки перехоплюються автоматично
  • NestJS: використовує фільтри виключень (@UseFilters()) для обробки async-помилок по всіх контролерах
  • Next.js API routes: відхилення Promise перехоплюються автоматично
  • AWS Lambda: async-обробники мають повертати Promise; необроблені відхилення спричиняють збій виклику

Питання на співбесіді

Q: Чому Promise.resolve(fn(req, res, next)) відрізняється від fn(req, res, next).catch(next)?
A: Promise.resolve() гарантує, що результат завжди є Promise, навіть якщо fn повертає звичайне значення або нічого. Без нього, якщо передати не-async функцію, виклик .catch() на не-Promise значенні викине помилку. Обидва варіанти працюють для async-функцій, але Promise.resolve() робить wrapper безпечнішим для змішаного коду.

Q: Чи можна використовувати async/await в error middleware Express?
A: Так, але потрібно загорнути. Сигнатура error middleware - (err, req, res, next), чотири аргументи. Express розпізнає її за кількістю параметрів. Загортай так само: app.use(asyncHandler((err, req, res, next) => { ... })).

Q: Якщо і middleware, і обробник маршруту мають wrapper, і middleware викидає помилку - який wrapper її обробляє?
A: Wrapper middleware ловить її і викликає next(err). Express пропускає весь залишковий не-error middleware і маршрути і йде прямо до error middleware. Тільки один wrapper обробляє кожну конкретну помилку. Вони не ланцюжаться.

Q: Як протестувати, що помилки правильно передаються до error middleware?
A: Замокай базу даних щоб вона відхиляла запит, потім перевіряй що error handler отримав помилку. З Jest: jest.spyOn(User, 'findAll').mockRejectedValue(new Error('DB down')), потім перевіряй що error middleware була викликана. Для інтеграційних тестів добре підходить Supertest.

Q (senior): Що відбувається з Promise.all(), якщо один із Promise відхиляється всередині asyncHandler?
A: Promise.all() відхиляється з першою помилкою. Це відхилення виходить через await, async-функція відхиляється, і .catch(next) wrapper-а його перехоплює. Інші Promise продовжують виконуватись у фоні, але їхні результати ігноруються. Якщо потрібно їх скасувати, знадобиться AbortController. Error handler отримує рівно одну помилку.

Приклади

Базовий: asyncHandler у контролері

js
// utils/asyncHandler.js const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); module.exports = asyncHandler; // routes/users.js const asyncHandler = require('../utils/asyncHandler'); app.post('/users', asyncHandler(async (req, res) => { const user = await User.create(req.body); // помилка БД перехоплена автоматично if (!user.email) throw new Error('Email required'); // теж перехоплено res.status(201).json(user); })); // Один error handler для всіх маршрутів app.use((err, req, res, next) => { console.error(err.message); res.status(500).json({ error: err.message }); });

І помилки бази даних, і помилки валідації потрапляють до error middleware. Логіку обробки помилок пишеш один раз, а не в кожному обробнику.

Середній: паралельні запити і timeout

js
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); function withTimeout(promise, ms = 5000) { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms) ); return Promise.race([promise, timeout]); } app.get('/dashboard', asyncHandler(async (req, res) => { // Всі три запити паралельно, а не послідовно const [users, posts, stats] = await Promise.all([ withTimeout(User.findAll(), 3000), withTimeout(Post.findRecent(), 3000), withTimeout(Stats.summary(), 3000), ]); res.json({ users, posts, stats }); }));

Послідовні await виконуються один за одним. Promise.all() запускає їх разом. На дашборді з трьома джерелами даних ця різниця відчутна.

Просунутий: fire-and-forget з правильною ізоляцією

js
app.get('/orders/:id/confirm', asyncHandler(async (req, res) => { const order = await Order.findById(req.params.id); await order.confirm(); // це має обов'язково пройти успішно // Email - best-effort, його помилка не повинна ламати запит sendConfirmationEmail(order).catch(err => { logger.error('Confirmation email failed', { orderId: order.id, err }); // Не кидаємо далі - замовлення підтверджено незалежно від email }); res.json({ confirmed: true, orderId: order.id }); }));

asyncHandler wrapper перехоплює те, що async-функція await-ить або повертає. Promise, запущений без await, поза його зоною відповідальності. Обробляй такі явно. Це патерн, який я бачив як джерело збоїв у продакшені, коли команди вважали що wrapper ловить абсолютно все.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?