Skip to main content

Шаблони обробки помилок у Node.js

Шаблони обробки помилок у Node.js - набір конвенцій для перехоплення та передачі помилок у коді на колбеках, Promise та async/await, щоб один необроблений збій не завалив увесь сервер.

Теорія

Коротко

  • Дві категорії: операційні помилки (таймаут БД, файл не знайдено) - обробляєш і відновлюєшся; помилки програміста (TypeError, неправильний аргумент) - виправляєш і рестартуєш
  • Колбеки використовують конвенцію error-first: перший аргумент завжди err або null
  • Promise використовують .catch(), async-функції - try/catch
  • В Express потрібен middleware з 4 аргументами (err, req, res, next) після всіх роутів
  • uncaughtException зобов'язаний викликати process.exit(1) - стан процесу після цієї події пошкоджено

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

Найпоширеніший сучасний підхід:

js
async function loadUserProfile(userId) { try { const user = await db.users.findById(userId); if (!user) { throw new NotFoundError('User'); // власна помилка з кодом статусу } return user; } catch (err) { logger.error({ userId, err }, 'Failed to load user profile'); throw err; // перекидаємо, щоб Express middleware сформував відповідь } }

Один try/catch накриває всі await у функції. Будь-яке відхилення потрапляє в catch. Окремий .catch() на кожен виклик не потрібен.

Операційні помилки vs помилки програміста

Це розрізнення часто виникає на співбесідах. Операційні помилки (operational errors) - очікувані: БД відключилась, юзер надіслав невалідні дані, файл не існує. Ти їх передбачаєш і пишеш код відновлення: повертаєш 404, робиш retry, повертаєш повідомлення валідації.

Помилки програміста - це баги. TypeError: Cannot read properties of undefined означає, що код невірний. Їх не "обробляють" gracefully. Логують, завершують процес і дають PM2 запустити сервер знову.

Саме тут і починаються більшість продакшн-інцидентів - коли плутають одне з одним.

Колбеки: error-first конвенція

До появи Promise кожен асинхронний API в Node.js використовував цю конвенцію:

js
const fs = require('fs'); fs.readFile('./config.json', 'utf8', (err, data) => { if (err) { // err.code: 'ENOENT' - файл не знайдено, 'EACCES' - немає прав console.error('Could not read config:', err.code); return; // зупинити - data тут undefined } const config = JSON.parse(data); startServer(config); });

Правило: завжди перевіряй err першим і завжди роби return після обробки. Пропустиш return - код нижче виконається з undefined у data. Цей патерн і досі зустрічається в Node.js core API (fs, dns, crypto) та старих кодових базах.

Promise та .catch()

Помилки в ланцюжку Promise передаються далі до першого .catch():

js
fetchUser(id) .then(user => enrichWithPosts(user)) // відхилення тут перескочить до .catch .then(enriched => formatResponse(enriched)) .catch(err => { console.error('Pipeline failed:', err.message); return { error: err.message }; // повертаємо fallback для відновлення }) .finally(() => metrics.record('fetchUser')); // виконується завжди

.finally() зручний для cleanup, який має відбутись незалежно від результату: закриття з'єднань, запис метрик, звільнення ресурсів.

async/await та try/catch

async/await - це синтаксичний цукор над Promise. Модель помилок та сама, але код виглядає як синхронний:

js
async function processOrder(orderId) { let order; try { order = await db.orders.findById(orderId); } catch (err) { throw new DatabaseError('Could not fetch order', err); } try { await paymentService.charge(order.totalAmount); } catch (err) { throw new PaymentError('Charge failed', err); } return order; }

Окремі блоки try/catch дають різні типи помилок для різних кроків. Один великий try/catch теж нормально, якщо потрібна однакова обробка всіх збоїв.

Middleware для помилок в Express

Express визначає error middleware за сигнатурою з 4 параметрами. Ставиться після всіх роутів:

js
// Обгортка для async-роутів: перекидає помилки в next() function asyncHandler(fn) { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; } app.get('/users/:id', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); if (!user) throw new NotFoundError('User'); res.json(user); })); // Централізований обробник помилок - останній middleware app.use((err, req, res, next) => { const status = err.statusCode || 500; res.status(status).json({ error: err.message, stack: process.env.NODE_ENV === 'development' ? err.stack : undefined, }); });

Без asyncHandler помилки з async-роутів ніколи не потраплять до error middleware. Обгортка викликає .catch(next), що передає помилку в pipeline Express.

Власні класи помилок

Кинути new Error('not found') - робочий варіант. Але HTTP API потребують кодів статусу, а error middleware має відрізняти твої помилки від несподіваних збоїв у сторонніх бібліотеках:

js
class AppError extends Error { constructor(message, statusCode = 500) { super(message); this.statusCode = statusCode; this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); // стек з місця throw } } class NotFoundError extends AppError { constructor(resource) { super(`${resource} not found`, 404); } } class ValidationError extends AppError { constructor(field, issue) { super(`${field}: ${issue}`, 422); this.field = field; } }

Error middleware перевіряє err instanceof AppError, щоб відрізнити очікувані помилки від сюрпризів. Error.captureStackTrace залишає стек-трейс таким, що вказує на місце виклику throw, а не на конструктор.

Глобальні обробники процесу

Це остання лінія захисту для всього, що пройшло крізь усі try/catch та .catch():

js
// Node.js 15+ падає за замовчуванням при unhandledRejection // Обробник дає можливість залогувати перед виходом process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection:', reason); process.exit(1); }); // Синхронний throw, що вийшов за межі всіх try/catch process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); process.exit(1); // обов'язково - стан процесу після цього невизначений });

Я бачив команди, що додавали process.on('uncaughtException') вважаючи, що це робить сервер стійким до збоїв. Ні. Це ховає краші і запускає зламаний код. Нормальне покриття try/catch і process manager, який рестартує сервер при виході - ось реальний захист.

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

Помилку ковтають у async-функції:

js
// Неправильно - caller отримує undefined, помилка зникає async function getUser(id) { try { return await fetchUser(id); } catch (err) { console.error(err); // логується, але не перекидається } } // Правильно async function getUser(id) { try { return await fetchUser(id); } catch (err) { console.error(err); throw err; } }

Забули asyncHandler в Express:

js
// Неправильно - відхилення ніколи не потрапить до error middleware app.get('/user/:id', async (req, res) => { const user = await User.findById(req.params.id); // unhandled rejection res.json(user); });

Promise.all() там, де допустима часткова відмова:

js
// Якщо хоч один падає - падають усі const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()]); // Promise.allSettled() повертає результат кожного окремо const results = await Promise.allSettled([fetchUsers(), fetchPosts()]); const users = results[0].status === 'fulfilled' ? results[0].value : []; const posts = results[1].status === 'fulfilled' ? results[1].value : [];

Надто широкий catch:

js
// Ловить TypeError з багів так само, як операційні помилки try { const data = await fetchData(); processData(data); // баг тут дасть 500 без жодної інформації } catch (err) { res.status(500).json({ error: 'Something went wrong' }); }

Окремий try/catch для fetch і обробка результату поза ним - або перевірка err instanceof для різних типів збоїв.

Де зустрічається в реальному коді

  • Express API: asyncHandler на кожному роуті і один error middleware в кінці
  • Шар бази даних: ловиш конкретні коди помилок (Postgres 23505 для duplicate) і кидаєш ValidationError
  • Зовнішні API: axios відхиляє на не-2xx відповідях, перевіряєш err.response.status у catch
  • Робота з файлами: err.code === 'ENOENT' відрізняє відсутній файл від помилки прав доступу
  • Паралельне завантаження даних: Promise.allSettled() коли кожна частина необов'язкова

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

Q: У чому різниця між unhandledRejection та uncaughtException?
A: unhandledRejection спрацьовує коли відхилений Promise не має .catch(). uncaughtException - коли синхронний throw вийшов за межі всіх try/catch. Обидва вимагають process.exit(1).

Q: Чому uncaughtException зобов'язаний викликати process.exit(1)?
A: Після uncaught exception стан процесу невизначений: відкриті з'єднання, таймери, I/O в польоті - все може бути в неконсистентному стані. Продовжувати обробляти запити означає повертати потенційно пошкоджені дані. Вихід і рестарт завжди безпечніший.

Q: Як Express знаходить error middleware серед звичайних?
A: Express перевіряє властивість .length функції. Middleware з рівно 4 параметрами (err, req, res, next) вважається обробником помилок. Коли викликається next(err), Express пропускає всі звичайні middleware до першого з 4 параметрами.

Q: Що робить Error.captureStackTrace(this, this.constructor) у власному класі помилки?
A: Встановлює стек-трейс починаючи з місця виклику throw, а не з середини конструктора AppError. Без цього вершина кожного стеку показує конструктор, що не допомагає знайти джерело помилки.

Q: Як отримати часткові результати коли одна з паралельних операцій падає?
A: Заміни Promise.all() на Promise.allSettled(). Він завжди резолвиться масивом де кожен елемент має status: 'fulfilled' і value, або status: 'rejected' і reason. Фільтруєш за статусом і дістаєш що пройшло.

Приклади

Async роут з централізованою обробкою помилок

js
const express = require('express'); const app = express(); class AppError extends Error { constructor(message, statusCode = 500) { super(message); this.statusCode = statusCode; Error.captureStackTrace(this, this.constructor); } } class NotFoundError extends AppError { constructor(resource) { super(`${resource} not found`, 404); } } function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } app.get('/orders/:id', asyncHandler(async (req, res) => { const order = await Order.findById(req.params.id); if (!order) throw new NotFoundError('Order'); res.json(order); })); // Після всіх роутів app.use((err, req, res, next) => { res.status(err.statusCode || 500).json({ error: err.message }); });

Роут-обробники залишаються чистими. Все формування відповідей на помилки в одному місці.

Обгортання callback API через util.promisify

js
const fs = require('fs'); const { promisify } = require('util'); const readFile = promisify(fs.readFile); async function loadConfig(filePath) { try { const raw = await readFile(filePath, 'utf8'); return JSON.parse(raw); } catch (err) { if (err.code === 'ENOENT') { // операційна помилка - файл ще не існує, повертаємо дефолти return { port: 3000, debug: false }; } // несподівана помилка - JSON parse або права доступу - перекидаємо throw err; } }

Перевірка err.code відрізняє очікуваний кейс (файл ще не створено) від реального збою (невалідний JSON, помилка диску).

Promise.allSettled() для дашборду

js
async function getDashboardData(userId) { const [ordersResult, profileResult, notificationsResult] = await Promise.allSettled([ fetchOrders(userId), fetchProfile(userId), fetchNotifications(userId), ]); return { orders: ordersResult.status === 'fulfilled' ? ordersResult.value : [], profile: profileResult.status === 'fulfilled' ? profileResult.value : null, notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : [], errors: [ordersResult, profileResult, notificationsResult] .filter(r => r.status === 'rejected') .map(r => r.reason?.message), }; }

Дашборд завантажується навіть якщо один сервіс недоступний. Кожна секція або повертає реальні дані, або безпечний fallback. Поле errors повідомляє клієнту, що саме не вдалось.

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

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

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

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