Пошук уроків, статей та іншого контенту
Винесете обробку винятків у єдиний механізм, щоб уніфікувати відповіді та спростити підтримку API.
У невеликому API помилку можна обробити безпосередньо в кожному маршруті:
app.get('/users/:id', async (req, res) => {
try {
const user = await findUser(req.params.id);
if (!user) {
return res.status(404).json({
error: 'Користувача не знайдено'
});
}
res.json(user);
} catch (error) {
res.status(500).json({
error: 'Внутрішня помилка сервера'
});
}
});Такий підхід швидко призводить до дублювання:
різні маршрути повертають помилки в різному форматі;
в одному місці використовується статус 400, а в іншому — 422 для тієї самої ситуації;
частина винятків може залишитися необробленою;
змінити формат відповіді потрібно в багатьох файлах;
внутрішні деталі помилки можуть випадково потрапити до клієнта.
Централізована обробка означає, що маршрути лише передають помилку далі, а єдиний обробник:
визначає HTTP-статус;
формує стандартну відповідь;
записує технічні деталі в журнал;
не розкриває клієнту внутрішню інформацію.
Для API зручно розділяти помилки на два типи.
Це ситуації, які передбачені логікою застосунку:
ресурс не знайдено;
вхідні дані некоректні;
користувач не має доступу;
запит конфліктує з поточним станом даних.
Клієнту можна безпечно повернути зрозуміле повідомлення і відповідний HTTP-статус.
Це помилки програмування, збої зовнішніх сервісів або інші непередбачені ситуації. Наприклад:
помилка підключення до бази даних;
звернення до неіснуючої властивості;
помилка в бібліотеці.
Такі помилки потрібно записати в журнал із технічними деталями, але клієнту повернути загальне повідомлення:
{
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "Внутрішня помилка сервера"
}
}Не варто повертати клієнту error.stack, SQL-запити, ключі конфігурації або назви внутрішніх файлів.
Стандартний об'єкт Error містить повідомлення і стек викликів, але для HTTP API часто потрібні додаткові властивості:
HTTP-статус;
стабільний код помилки;
ознака очікуваної помилки.
Для цього можна створити власний клас:
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_SERVER_ERROR') {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
this.isOperational = statusCode < 500;
}
}Приклад створення помилки:
throw new AppError(
'Користувача не знайдено',
404,
'USER_NOT_FOUND'
);Важливо, що code — це не HTTP-статус. HTTP-статус описує результат на рівні протоколу, а code є стабільним ідентифікатором помилки для клієнтського коду.
Наприклад, клієнт може перевіряти USER_NOT_FOUND, не залежачи від тексту повідомлення.
В Express middleware для помилок має спеціальний підпис із чотирма параметрами:
(error, request, response, next)Наявність саме чотирьох параметрів повідомляє Express, що це обробник помилок.
У синхронному маршруті помилку можна передати через next(error):
app.get('/example', (request, response, next) => {
try {
throw new Error('Помилка під час обробки запиту');
} catch (error) {
next(error);
}
});Або скористатися next безпосередньо там, де виникла помилка:
app.get('/example', (request, response, next) => {
next(new AppError('Некоректний запит', 400, 'INVALID_REQUEST'));
});Асинхронні функції можуть відхилити Promise. Щоб однаково передавати такі помилки до централізованого обробника, зручно створити обгортку:
const asyncHandler = (handler) => {
return (request, response, next) => {
Promise
.resolve(handler(request, response, next))
.catch(next);
};
};Тепер маршрут може містити throw, а обгортка передасть цю помилку до next:
app.get('/users/:id', asyncHandler(async (request, response) => {
const user = await findUser(request.params.id);
if (!user) {
throw new AppError('Користувача не знайдено', 404, 'USER_NOT_FOUND');
}
response.json(user);
}));Без такої обгортки або іншого механізму передавання помилок відхилений Promise може не потрапити до обробника помилок у версіях Express, де асинхронні помилки не обробляються автоматично.
Нижче наведено runnable-приклад. Він використовує Express і зберігає дані в пам'яті, тому для запуску не потрібна база даних.
Встановіть залежність:
npm init -y
npm install expressСтворіть файл server.js:
const express = require('express');
const app = express();
const port = 3000;
app.use(express.json());
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_SERVER_ERROR') {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
this.isOperational = statusCode < 500;
}
}
const asyncHandler = (handler) => {
return (request, response, next) => {
Promise
.resolve(handler(request, response, next))
.catch(next);
};
};
const users = [
{ id: 1, name: 'Олена' },
{ id: 2, name: 'Андрій' }
];
const findUserById = async (id) => {
return users.find((user) => user.id === id);
};
app.get('/users/:id', asyncHandler(async (request, response) => {
const id = Number(request.params.id);
if (!Number.isInteger(id)) {
throw new AppError(
'Ідентифікатор користувача має бути цілим числом',
400,
'INVALID_USER_ID'
);
}
const user = await findUserById(id);
if (!user) {
throw new AppError(
'Користувача не знайдено',
404,
'USER_NOT_FOUND'
);
}
response.json({
data: user
});
}));
app.post('/users', asyncHandler(async (request, response) => {
const { name } = request.body;
if (typeof name !== 'string' || name.trim().length < 2) {
throw new AppError(
'Ім’я має містити щонайменше два символи',
400,
'INVALID_USER_NAME'
);
}
const user = {
id: users.length + 1,
name: name.trim()
};
users.push(user);
response.status(201).json({
data: user
});
}));
app.get('/debug-error', asyncHandler(async () => {
// Імітуємо неочікувану помилку програмування
throw new TypeError('Неочікувана помилка в коді');
}));
app.use((request, response, next) => {
next(new AppError(
'Маршрут не знайдено',
404,
'ROUTE_NOT_FOUND'
));
});
app.use((error, request, response, next) => {
if (response.headersSent) {
return next(error);
}
const statusCode = Number.isInteger(error.statusCode)
? error.statusCode
: 500;
const isOperational = error.isOperational === true;
if (!isOperational) {
console.error(error);
}
response.status(statusCode).json({
error: {
code: isOperational
? error.code
: 'INTERNAL_SERVER_ERROR',
message: isOperational
? error.message
: 'Внутрішня помилка сервера'
}
});
});
app.listen(port, () => {
console.log(`Сервер запущено на http://localhost:${port}`);
});Запустіть сервер:
node server.jsПриклади запитів:
curl http://localhost:3000/users/1Відповідь:
{
"data": {
"id": 1,
"name": "Олена"
}
}Запит до неіснуючого користувача:
curl http://localhost:3000/users/99Відповідь:
{
"error": {
"code": "USER_NOT_FOUND",
"message": "Користувача не знайдено"
}
}Запит до неіснуючого маршруту:
curl http://localhost:3000/unknownВідповідь:
{
"error": {
"code": "ROUTE_NOT_FOUND",
"message": "Маршрут не знайдено"
}
}Для неочікуваної помилки маршрут /debug-error поверне лише загальну відповідь:
{
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "Внутрішня помилка сервера"
}
}При цьому повна технічна інформація буде виведена в консоль сервера.
Порядок підключення middleware має значення.
Типова послідовність така:
middleware для розбору тіла запиту;
маршрути;
обробник неіснуючих маршрутів;
централізований обробник помилок.
Обробник помилок, розміщений перед маршрутами, не зможе обробити помилки, які виникнуть у маршрутах після нього.
Обробник неіснуючих маршрутів також має бути перед обробником помилок. Він перетворює відсутність маршруту на звичайну помилку AppError, яку потім обробляє останній middleware.
Централізований обробник має повертати однакову структуру для всіх помилок. Наприклад:
{
"error": {
"code": "INVALID_USER_ID",
"message": "Ідентифікатор користувача має бути цілим числом"
}
}Переваги такого формату:
клієнт завжди знає, де шукати помилку;
код помилки можна використовувати в умовах;
текст повідомлення можна змінити без зміни логіки клієнта;
помилки від різних маршрутів виглядають однаково.
Не слід повертати різні структури на кшталт:
{ "message": "Помилка" }{ "error": "Помилка" }{ "errors": ["Помилка"] }Якщо API має кілька форматів помилок без чіткої причини, його складніше використовувати і тестувати.
headersSentІноді відповідь уже частково відправлена клієнту до моменту виникнення помилки. Наприклад, сервер почав передавати потокові дані.
У такій ситуації не можна надійно змінити статус або тіло відповіді. Тому обробник перевіряє:
if (response.headersSent) {
return next(error);
}Виклик next(error) передає помилку стандартному механізму Express, який може коректно завершити з'єднання.
Для звичайних JSON-відповідей ця ситуація виникає рідко, але перевірку варто залишати в загальному обробнику.
Якщо кожен маршрут сам формує відповідь про помилку, формат API поступово стає непослідовним.
Краще створювати помилку в маршруті, а форматування залишати централізованому обробнику.
return після відповідіПомилка:
if (!user) {
response.status(404).json({ error: 'Не знайдено' });
}
response.json(user);Після першої відповіді виконання продовжиться, і сервер спробує відправити другу відповідь.
Правильний варіант:
if (!user) {
return response.status(404).json({ error: 'Не знайдено' });
}
response.json(user);Якщо використовується throw new AppError(...), додатковий return не потрібен, оскільки виконання функції одразу припиняється.
Це звичайний middleware, а не обробник помилок:
app.use((error, request, response) => {
response.status(500).json({ error: error.message });
});В обробника помилок має бути чотири параметри:
app.use((error, request, response, next) => {
// Обробка помилки
});Навіть якщо next не використовується безпосередньо, він потрібен для розпізнавання middleware як обробника помилок.
error.message для кожної помилкиПовідомлення неочікуваної помилки може містити внутрішні деталі. Не можна безумовно робити так:
response.status(500).json({
error: error.message
});Для помилок зі статусом 500 клієнту краще повертати загальне повідомлення, а повну помилку записувати в журнал.
next для асинхронних помилокАсинхронний маршрут має бути підключений через обгортку або інший механізм, який передає відхилений Promise до next.
Інакше throw усередині асинхронної функції може не потрапити до централізованого обробника.
Для централізованого механізму достатньо одного базового класу з властивостями statusCode і code. Окремі класи для кожної помилки потрібні лише тоді, коли вони справді додають різну поведінку.
Централізований обробник помилок у Express має сигнатуру (error, request, response, next).
Маршрути передають помилки через throw або next(error), а не формують усі відповіді самостійно.
Для очікуваних помилок зручно використовувати власний клас із HTTP-статусом і кодом помилки.
Асинхронні маршрути потрібно обгорнути механізмом, який передає відхилення до next.
Обробник неіснуючих маршрутів розміщують перед загальним обробником помилок.
Неочікувані помилки потрібно журналювати, але не розкривати клієнту їхні внутрішні деталі.
Єдиний формат відповіді спрощує роботу клієнтів, тестування і подальшу підтримку API.