Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як обробляти async/await в обробниках маршрутів Express.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Async/await в обробниках маршрутів Express.js** - Express не перехоплює відхилення Promise з async-функцій автоматично, тому помилки оминають error middleware. ```js 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(); // відхилення передається до error handler res.json(users); })); ``` **Ключове:** загортай кожен async-обробник і middleware у `asyncHandler`, або використовуй `express-async-errors`, або переходь на Express 5, який підтримує це нативно.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 ловить абсолютно все.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.