Як обробляти помилки в Express.js?
Обробка помилок в Express.js - механізм, який перехоплює помилки з обробників маршрутів і передає їх спеціальному error middleware, що формує відповідь замість того, щоб крашити процес.
Теорія
TL;DR
- Синхронні помилки в маршрутах Express перехоплює автоматично
- Async помилки (promise, await) НЕ перехоплюються автоматично - потрібен try/catch і
next(err) - Error middleware має рівно 4 параметри
(err, req, res, next)і реєструється останнім - Обгортка
asyncHandlerпозбавляє від повторення try/catch у кожному маршруті - В Node 15+ необроблений rejection крашить процес
Швидкий приклад
// НЕПРАВИЛЬНО: async помилка обходить error handling Express
app.get('/user/:id', async (req, res) => {
const user = await User.findById(req.params.id); // rejection не перехоплено
res.json(user);
});
// ПРАВИЛЬНО: перехоплюємо і передаємо в error middleware
app.get('/user/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
res.json(user);
} catch (err) {
next(err); // направляє в error-handling middleware
}
});
// Error middleware - 4 параметри, реєструється останнім
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});Express бачить 4-параметровий підпис і розуміє, що це обробник помилок, а не звичайний middleware.
Синхронні vs асинхронні помилки
Express загортає синхронні обробники маршрутів у внутрішній блок try/catch. Коли ти throw-иш у синхронному обробнику, Express перехоплює помилку і передає її далі по ланцюгу error middleware.
З async обробниками інша картина. Коли викликається async функція, вона одразу повертає Promise. Express викликає обробник, отримує Promise і рухається далі. Якщо той Promise відхиляється пізніше, Express вже "пішов" від цього обробника - rejection відбувається поза межами його try/catch. Node.js генерує подію unhandledRejection, і в Node 15+ це завершення процесу.
Саме тут найчастіше виникають непомітні збої в Express-застосунках. Один пропущений try/catch у популярному маршруті - і сервер падає.
Коли що використовувати
- Синхронний код у маршруті -
throwfreely, Express перехоплює сам - async/await у маршруті - обгорни в try/catch, у catch блоці виклич
next(err) - Callbacks (старий код) - виклич
next(err)в error-шляху callback - Багато async маршрутів - використовуй обгортку
asyncHandler, щоб не писати try/catch скрізь - Неопрацьовані маршрути (404) - додай catch-all middleware після всіх маршрутів, перед error handler
Патерн asyncHandler
Писати try/catch у кожному маршруті швидко набридає. Обгортка asyncHandler вирішує це:
// Обгортка перехоплює rejected promises і передає в next()
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// try/catch у маршруті більше не потрібен
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
const err = new Error('Not found');
err.status = 404;
throw err; // обгортка перехопить і викличе next(err)
}
res.json(user);
}));Обгортка резолвить повернутий Promise і, якщо він відхиляється, передає помилку напряму в next. Саме цей патерн використовують бібліотеки express-async-errors та express-async-handler на npm.
Власні класи помилок
Для простих випадків достатньо додати status до звичайного Error. Але власні класи помилок роблять обробку структурованішою при зростанні застосунку:
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // прапор для "очікуваних" помилок
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
}
}В error middleware перевіряй err instanceof NotFoundError, щоб по-різному обробляти різні типи помилок. Операційні помилки (поганий input, відсутній ресурс) несуть власні статус-коди. Несподівані помилки отримують 500.
Як Express розпізнає error middleware
Express визначає, чи є middleware обробником помилок, перевіряючи властивість .length функції - кількість оголошених параметрів. Звичайний middleware має 2 або 3 параметри. Error middleware - рівно 4. Напишеш (err, req, res) - 3 параметри - Express сприйме це як звичайний middleware і пропустить для помилок.
Тому параметр next в error middleware потрібно завжди оголошувати, навіть якщо ніколи не викликаєш його.
Типові помилки
Помилка 1: async обробник без try/catch
// НЕПРАВИЛЬНО: rejection не перехоплено, Node 15+ завершить процес
app.get('/data', async (req, res) => {
const data = await fetchData();
res.json(data);
});
// ПРАВИЛЬНО
app.get('/data', async (req, res, next) => {
try {
const data = await fetchData();
res.json(data);
} catch (err) {
next(err);
}
});Express загортає тільки синхронну частину обробника. Async частина виконується після того, як обробник вже повернув значення.
Помилка 2: error middleware зареєстровано перед маршрутами
// НЕПРАВИЛЬНО: обробник помилок реєструється першим - маршрути його не досягнуть
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
app.get('/users', async (req, res, next) => { /* ... */ });
// ПРАВИЛЬНО: спочатку маршрути, потім error handler
app.get('/users', async (req, res, next) => { /* ... */ });
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});Express обробляє middleware в порядку реєстрації. Error handler, зареєстрований до маршрутів, ніколи не буде в ланцюзі виконання цих маршрутів.
Помилка 3: виклик next() до async операції
// НЕПРАВИЛЬНО
app.get('/user/:id', async (req, res, next) => {
next(); // каже Express "тут закінчив"
const user = await User.findById(req.params.id); // помилка тут - без обробника
res.json(user);
});Як тільки next() викликано без аргументу помилки, Express переходить до наступного middleware. Будь-яка помилка після цього не має обробника.
Помилка 4: одночасна відправка відповіді і передача помилки
// НЕПРАВИЛЬНО: і res.json(), і next(err) можуть виконатись одночасно
app.get('/user/:id', async (req, res, next) => {
const user = await User.findById(req.params.id).catch(next);
res.json(user); // виконається навіть якщо .catch(next) вже спрацював
});
// ПРАВИЛЬНО: або відправляємо відповідь, або передаємо помилку
app.get('/user/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(user);
} catch (err) {
next(err);
}
});Після виклику res.json() або res.send() відповідь відправлено. Повторний виклик дає "Cannot set headers after they are sent".
Помилка 5: однакова обробка всіх помилок
// НЕПРАВИЛЬНО: все стає 500
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
// ПРАВИЛЬНО: беремо статус з об'єкта помилки
app.use((err, req, res, next) => {
const status = err.statusCode || err.status || 500;
const message = err.message || 'Internal server error';
if (!err.isOperational) {
console.error('Unexpected error:', err); // логуємо програмні помилки
}
res.status(status).json({
error: { status, message },
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});Операційні помилки (NotFoundError, ValidationError) несуть власні статус-коди і є очікуваними. Програмні помилки потрібно логувати і повертати 500. Однакова обробка ховає реальні баги.
Де зустрічається в реальних проектах
- Express-застосунки - async маршрути потребують try/catch +
next(err)або обгортки asyncHandler - express-async-errors - npm пакет, який патчить Express для автоматичної обробки async помилок
- NestJS - використовує exception filters замість middleware, та сама ідея з іншим синтаксисом
- Fastify - вбудована обробка async помилок, обгортка не потрібна
- Koa - використовує try/catch прямо в middleware, без патерну
next(err)
Follow-up питання
Q: Чому Express не перехоплює помилки в async обробниках автоматично?
A: Express загортає синхронне виконання обробника в try/catch. Async функція одразу повертає Promise, тому до моменту, коли Promise відхиляється, обробник вже повернув значення і try/catch закрився. Rejection відбувається поза областю видимості Express.
Q: Що відбувається з необробленими promise rejection у Node 15+?
A: Node.js завершує процес. До Node 15 це видавало лише deprecation попередження. Тому залишати async помилки без обробки в Express - це не просто погана практика, це краш продакшену.
Q: Чи можна мати кілька error-handling middleware функцій?
A: Так. Express викликає їх по порядку, поки одна не припинить викликати next(err). Можна мати спеціалізовані обробники для validation і database помилок перед загальним catch-all. Кожен має оголошувати рівно 4 параметри.
Q: Що відбувається, якщо error middleware сам кидає помилку?
A: Express перехоплює її і відправляє загальну відповідь 500. Error middleware має бути захищеним: перевіряй властивості err, використовуй значення за замовчуванням, не викликай зовнішній код без захисту.
Q: Як організувати обробку помилок у великому застосунку з різними доменами (auth, users, payments)?
A: Створи власні класи помилок для кожного домену - AuthenticationError, PaymentError, ValidationError - кожен з властивістю statusCode. В маршрутах throw потрібний клас. У глобальному error handler перевіряй instanceof для різного форматування: AuthenticationError повертає 401 без внутрішніх деталей, ValidationError - 400 з описом полів, несподівані помилки - 500 і запис у лог. Це відокремлює логіку форматування від бізнес-логіки і робить кожен error path тестованим.
Приклади
Базовий: синхронна vs асинхронна обробка помилок
const express = require('express');
const app = express();
// Sync: Express перехоплює автоматично
app.get('/sync', (req, res) => {
throw new Error('Something went wrong'); // try/catch не потрібен
});
// Async: потрібно перехопити самостійно і передати в next
app.get('/async', async (req, res, next) => {
try {
const data = await fetchSomething();
res.json(data);
} catch (err) {
next(err); // передаємо в error middleware
}
});
// Error middleware - завжди останній, завжди 4 параметри
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});
app.listen(3000);Синхронний throw одразу потрапляє в error middleware. Async помилка без try/catch крашить процес в Node 15+.
Середній: продакшн маршрут з валідацією і помилками бази даних
app.post('/users', async (req, res, next) => {
try {
// операційна помилка - поганий input від клієнта
if (!req.body.email) {
const err = new Error('Email is required');
err.status = 400;
throw err;
}
const user = await User.create(req.body);
res.status(201).json(user);
} catch (err) {
next(err); // всі помилки йдуть в error handler
}
});
app.use((err, req, res, next) => {
const status = err.status || 500;
const message = err.message || 'Internal server error';
res.status(status).json({
error: { status, message },
// stack видно лише в розробці
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});Помилки валідації повертають 400. Несподівані помилки бази даних - 500. Stack trace видно лише в режимі розробки.
Просунутий: повне налаштування з власними класами помилок і asyncHandler
// Власні класи помилок
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
class ValidationError extends AppError {
constructor(message) { super(message, 400); }
}
// Обгортка asyncHandler - без try/catch у маршрутах
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Маршрут без boilerplate
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new NotFoundError('User'); // обгортка передасть в next()
res.json(user);
}));
// 404 для неопрацьованих маршрутів - після всіх маршрутів
app.use((req, res, next) => {
next(new NotFoundError(`Route ${req.method} ${req.path}`));
});
// Глобальний error handler - завжди останній
app.use((err, req, res, next) => {
const status = err.statusCode || err.status || 500;
if (!err.isOperational) {
console.error('Unexpected error:', err); // логуємо несподівані помилки
}
res.status(status).json({
error: err.message,
...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
});
});isOperational позначає помилки, які ти очікуєш (поганий input, відсутні записи). Несподівані помилки логуються з повним stack trace перед тим, як клієнту йде загальний 500.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.