Пошук уроків, статей та іншого контенту
Класифікуєте помилки бази даних, оброблятимете їх у Node.js і повертатимете клієнту коректні відповіді.
Помилка бази даних виникає, коли база не може виконати операцію або з’єднання з нею недоступне. У Node.js така помилка зазвичай передається в catch або потрапляє до обробника помилок Express.
Приклади:
спроба вставити дубльоване значення в поле з обмеженням UNIQUE;
порушення зовнішнього ключа;
передавання NULL у поле NOT NULL;
неправильний формат значення;
втрата з’єднання з базою;
взаємне блокування транзакцій;
скасування або перевищення часу виконання запиту.
Обробляти всі такі ситуації однаково не можна. Клієнту потрібно повертати зрозумілий HTTP-статус, а внутрішні деталі помилки — записувати в журнал, не розкриваючи їх користувачу.
Ці помилки означають, що запит містить некоректні або конфліктні дані.
| Тип помилки | Код PostgreSQL | Приклад HTTP-відповіді | |---|---:|---:| | Порушення унікальності | 23505 | 409 Conflict | | Порушення зовнішнього ключа | 23503 | 409 Conflict | | Порушення NOT NULL | 23502 | 400 Bad Request
22P02400 Bad RequestCHECK23514400 Bad RequestТакі помилки зазвичай не означають несправність сервера. Клієнт може змінити дані та повторити запит.
Конфлікт виникає, коли запит формально правильний, але його результат суперечить поточному стану даних.
Наприклад, користувач намагається зареєструвати електронну адресу, яка вже існує. База повертає 23505, а сервер — 409 Conflict.
До цієї групи належать:
недоступність бази даних;
вичерпання пулу з’єднань;
тайм-аут;
serialization failure;
deadlock.
Такі помилки можуть зникнути після повторної спроби. Сервер зазвичай повертає 503 Service Unavailable або інший статус, який відповідає політиці застосунку.
Іноді помилка бази вказує на дефект у коді:
неправильний SQL-запит;
використання неіснуючої колонки;
передавання параметрів у неправильному порядку;
невірна назва таблиці.
Клієнту не варто повертати текст такої помилки. Він може містити SQL, назви таблиць, структуру бази або іншу службову інформацію. Для клієнта це зазвичай 500 Internal Server Error, а повний об’єкт помилки потрібно записати в журнал.
pgДля роботи з PostgreSQL у Node.js часто використовується пакет pg. Помилка від драйвера має властивість code — SQLSTATE-код PostgreSQL.
Наприклад:
{
code: '23505',
constraint: 'users_email_key',
detail: 'Key (email)=(anna@example.com) already exists.',
table: 'users',
column: undefined
}Найважливіші властивості:
code — машинний код помилки;
constraint — назва обмеження;
table — таблиця, де виникла помилка;
column — колонка;
detail — деталі від PostgreSQL;
message — технічне повідомлення.
detail і message корисні для журналювання, але їх не слід безпосередньо відправляти клієнту.
Зручно створити окрему функцію, яка перетворює помилку бази на безпечну відповідь:
function databaseErrorToHttp(error) {
switch (error.code) {
case '23505':
return {
status: 409,
body: {
error: 'conflict',
message: 'Запис із такими даними вже існує.'
}
};
case '23503':
return {
status: 409,
body: {
error: 'conflict',
message: 'Пов’язаний запис не існує або не може бути змінений.'
}
};
case '23502':
case '22P02':
case '23514':
return {
status: 400,
body: {
error: 'invalid_data',
message: 'Передані некоректні дані.'
}
};
case '40001':
case '40P01':
case '57014':
return {
status: 503,
body: {
error: 'temporary_database_error',
message: 'Тимчасово не вдалося виконати операцію. Спробуйте ще раз.'
}
};
case '08000':
case '08001':
case '08003':
case '08004':
case '08006':
case '080 essence':
return {
status: 503,
body: {
error: 'database_unavailable',
message: 'Сервіс тимчасово недоступний.'
}
};
default:
return null;
}
}У наведеному прикладі 080 essence не є кодом PostgreSQL і не повинен використовуватися в реальному застосунку. Список кодів потрібно формувати лише з кодів, які підтримує конкретний драйвер і база даних.
Коректний варіант для кодів з’єднання:
const connectionErrorCodes = new Set([
'08000',
'08001',
'08003',
'08004',
'08006'
]);
function databaseErrorToHttp(error) {
if (error.code === '23505') {
return {
status: 409,
body: {
error: 'conflict',
message: 'Запис із такими даними вже існує.'
}
};
}
if (error.code === '23503') {
return {
status: 409,
body: {
error: 'conflict',
message: 'Пов’язаний запис не існує або не може бути змінений.'
}
};
}
if (['23502', '22P02', '23514'].includes(error.code)) {
return {
status: 400,
body: {
error: 'invalid_data',
message: 'Передані некоректні дані.'
}
};
}
if (['40001', '40P01', '57014'].includes(error.code)) {
return {
status: 503,
body: {
error: 'temporary_database_error',
message: 'Тимчасово не вдалося виконати операцію. Спробуйте ще раз.'
}
};
}
if (connectionErrorCodes.has(error.code)) {
return {
status: 503,
body: {
error: 'database_unavailable',
message: 'Сервіс тимчасово недоступний.'
}
};
}
return null;
}Коди 08000 та інші SQLSTATE-коди з’єднання можуть з’являтися залежно від конкретної ситуації й версії драйвера. Тому важливо також перевіряти помилки без code, наприклад помилки мережі або помилки створення пулу.
Розглянемо API для створення користувача. База має таблицю:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
);Повний приклад сервера:
const express = require('express');
const { Pool } = require('pg');
const app = express();
const port = process.env.PORT || 3000;
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
app.use(express.json());
function databaseErrorToHttp(error) {
if (error.code === '23505') {
return {
status: 409,
body: {
error: 'conflict',
message: 'Користувач із такою електронною адресою вже існує.'
}
};
}
if (error.code === '23503') {
return {
status: 409,
body: {
error: 'conflict',
message: 'Пов’язаний запис не існує або не може бути змінений.'
}
};
}
if (['23502', '22P02', '23514'].includes(error.code)) {
return {
status: 400,
body: {
error: 'invalid_data',
message: 'Передані некоректні дані.'
}
};
}
if (['40001', '40P01', '57014'].includes(error.code)) {
return {
status: 503,
body: {
error: 'temporary_database_error',
message: 'Тимчасово не вдалося виконати операцію. Спробуйте ще раз.'
}
};
}
return null;
}
app.post('/users', async (req, res, next) => {
const { email, name } = req.body;
// Перевіряємо очевидно некоректний запит до звернення до бази.
if (
typeof email !== 'string' ||
typeof name !== 'string' ||
email.trim() === '' ||
name.trim() === ''
) {
return res.status(400).json({
error: 'invalid_request',
message: 'Поля email і name є обов’язковими.'
});
}
try {
const result = await pool.query(
`
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id, email, name
`,
[email.trim(), name.trim()]
);
return res.status(201).json(result.rows[0]);
} catch (error) {
return next(error);
}
});
app.use((error, req, res, next) => {
const mappedError = databaseErrorToHttp(error);
if (mappedError) {
console.error('Помилка бази даних:', {
code: error.code,
constraint: error.constraint,
table: error.table,
column: error.column,
message: error.message
});
return res.status(mappedError.status).json(mappedError.body);
}
console.error('Невідома помилка:', error);
return res.status(500).json({
error: 'internal_error',
message: 'Внутрішня помилка сервера.'
});
});
app.listen(port, () => {
console.log(`Сервер запущено на порту ${port}`);
});У цьому прикладі:
очевидно неправильні дані перевіряються до SQL-запиту;
значення передаються параметрами $1 і $2;
помилка передається до централізованого middleware через next(error);
відомі помилки бази перетворюються на конкретні HTTP-відповіді;
невідомі помилки не розкриваються клієнту;
технічні деталі записуються в журнал.
Для запуску потрібні пакети express і pg, а також змінна середовища DATABASE_URL, яка вказує на доступну базу PostgreSQL.
Перевірка у Node.js покращує повідомлення для клієнта, але не гарантує цілісність даних.
Наприклад, код може перевірити, що електронна адреса ще не використовується:
const existingUser = await pool.query(
'SELECT id FROM users WHERE email = $1',
[email]
);
if (existingUser.rowCount > 0) {
return res.status(409).json({
error: 'conflict',
message: 'Користувач уже існує.'
});
}Однак між SELECT та INSERT інший запит може створити користувача з такою самою адресою. Це класична проблема змагання.
Тому поле все одно повинно мати обмеження UNIQUE, а сервер — обробляти 23505. Перевірка в Node.js потрібна для зручності, але остаточним захистом є обмеження бази.
Якщо одна транзакція містить кілька операцій, після помилки її потрібно відкотити. Для цього використовується ROLLBACK.
const client = await pool.connect();
try {
await client.query('BEGIN');
const userResult = await client.query(
`
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING id
`,
[email, name]
);
await client.query(
`
INSERT INTO audit_log (user_id, action)
VALUES ($1, $2)
`,
[userResult.rows[0].id, 'user_created']
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}Важливі правила:
після першої помилки викликайте ROLLBACK;
завжди звільняйте клієнт у finally;
не використовуйте окремий pool.query() для частини операцій транзакції;
не робіть COMMIT, якщо одна з операцій завершилася помилкою.
Якщо транзакцію не відкотити, з’єднання може залишитися в стані помилки, а наступні запити на цьому з’єднанні також не виконуватимуться.
Не кожну помилку можна безпечно повторити.
Без повторної спроби потрібно обробляти:
23505 — повторення знову створить конфлікт;
23503 — повторення не виправить відсутній пов’язаний запис;
23502 — дані залишаються неповними;
22P02 — формат значення не зміниться.
Повторна спроба може бути доречною для тимчасових помилок:
40001 — serialization failure;
40P01 — deadlock detected;
окремих помилок з’єднання.
Повторення має бути обмеженим: наприклад, одна або дві спроби з невеликою затримкою. Нескінченні повтори можуть перевантажити базу та збільшити час відповіді.
Не слід повертати клієнту такий об’єкт:
res.status(500).json(error);Він може містити:
SQL-запит;
назви таблиць і колонок;
структуру обмежень;
службові шляхи;
внутрішні деталі драйвера;
конфіденційну інформацію.
Краще використовувати стабільний формат:
res.status(409).json({
error: 'conflict',
message: 'Запис із такими даними вже існує.'
});Клієнт може перевіряти поле error, а текст message показувати користувачу. Внутрішній журнал при цьому може містити більше технічних деталей.
Текст error.message може відрізнятися між версіями PostgreSQL або драйвера. Для класифікації використовуйте error.code.
Поганий варіант:
if (error.message.includes('duplicate')) {
// ...
}Кращий варіант:
if (error.code === '23505') {
// Обробка порушення унікальності.
}500 для всіх помилокПорушення UNIQUE — це не внутрішня помилка сервера. Відповідь 409 дозволяє клієнту зрозуміти, що виник конфлікт даних.
message базиТехнічне повідомлення може допомогти зловмиснику досліджувати структуру системи. Клієнт повинен отримувати безпечне узагальнене повідомлення.
ROLLBACKПісля помилки в транзакції потрібно виконати відкат. Інакше транзакція не буде придатною для подальших запитів.
Після pool.connect() потрібно викликати client.release() у блоці finally. Інакше пул поступово втратить усі доступні з’єднання.
Перевірка до запиту не захищає від одночасних запитів. Обмеження UNIQUE, FOREIGN KEY, NOT NULL та CHECK повинні залишатися в базі.
Помилки бази потрібно класифікувати за кодом, а не за текстом повідомлення.
Помилки даних зазвичай перетворюються на 400 або 409.
Тимчасові помилки бази можуть повертати 503.
Невідомі помилки потрібно логувати та приховувати від клієнта за 500.
Обмеження бази є остаточним захистом цілісності даних.
У транзакції після помилки потрібно виконати ROLLBACK.
Клієнт пулу слід звільняти в finally.
Повторні спроби доречні лише для тимчасових і безпечних для повторення операцій.