Пошук уроків, статей та іншого контенту
Обробимо помилки Prisma й PostgreSQL, перетворимо їх на зрозумілі HTTP-відповіді та додамо логування.
Prisma повертає технічні помилки, які зручні для розробника, але непридатні як відповідь публічного API:
P2002 означає порушення унікальності;
P2025 означає, що запис не знайдено;
P2003 означає порушення зовнішнього ключа;
PostgreSQL додатково може повертати SQLSTATE-коди, наприклад 23505;
повідомлення помилки можуть містити назви таблиць, колонок або фрагменти SQL.
Якщо не обробляти ці помилки, NestJS зазвичай повертає 500 Internal Server Error. Клієнт не розуміє, що саме сталося, а в логах і відповідях можуть опинитися зайві внутрішні деталі.
Оптимальна схема:
Сервіс виконує операцію через Prisma.
Помилка піднімається до глобального exception filter.
Filter розпізнає тип помилки та її код.
У лог записується технічна інформація.
Клієнту повертається стабільна HTTP-відповідь без деталей реалізації.
P2002 — порушення унікальностіВиникає, коли значення порушує @unique або складений унікальний індекс.
Наприклад, якщо поле email має обмеження @unique, повторне створення користувача може спричинити:
Unique constraint failed on the fields: (`email`)Для HTTP API це зазвичай:
409 ConflictP2025 — запис не знайденоВиникає, наприклад, під час update, delete або findUniqueOrThrow, якщо потрібного запису не існує.
Для HTTP API:
404 Not FoundP2003 — порушення зовнішнього ключаПриклад: видалення користувача, до якого ще належать пов’язані записи.
Для HTTP API найчастіше підходить:
409 ConflictЦе означає, що поточна операція конфліктує зі станом даних.
P2000 — значення завелике для колонкиЗначення не поміщається у визначений тип або розмір колонки.
Зазвичай це:
400 Bad RequestОднак коректна валідація DTO має відсікати більшість таких помилок ще до звернення до бази даних.
P2010 — помилка виконання raw SQLЦей код часто містить у meta код PostgreSQL, наприклад:
23505 — порушення унікальності;
23503 — порушення зовнішнього ключа;
23502 — NOT NULL violation.
Для Prisma-моделей зазвичай достатньо обробки P2002, P2003 та інших відомих кодів. P2010 особливо важливий, якщо застосунок використовує $queryRaw або $executeRaw.
Не варто дублювати try/catch у кожному методі сервісу:
try {
return this.prisma.user.create({ data });
} catch (error) {
// Однакова логіка дублюється в багатьох місцях
}Такий підхід призводить до:
різних форматів відповідей;
пропущених типів помилок;
повторення логіки;
випадкового повернення error.message клієнту.
Замість цього створимо один глобальний filter для помилок Prisma.
// src/common/filters/prisma-exception.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
type ErrorResponse = {
statusCode: number;
error: string;
message: string;
code?: string;
};
type PrismaMeta = {
target?: string[];
field_name?: string;
cause?: string;
code?: string;
message?: string;
};
@Catch(
Prisma.PrismaClientKnownRequestError,
Prisma.PrismaClientUnknownRequestError,
Prisma.PrismaClientValidationError,
)
export class PrismaExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(PrismaExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp();
const request = context.getRequest<Request>();
const response = context.getResponse();
const result = this.mapException(exception);
this.writeLog(exception, request, result);
response.status(result.status).json(result.body);
}
private mapException(exception: unknown): {
status: number;
body: ErrorResponse;
internalCode: string;
postgresCode?: string;
} {
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
const meta = this.getMeta(exception.meta);
if (exception.code === 'P2002') {
return {
status: HttpStatus.CONFLICT,
internalCode: exception.code,
body: {
statusCode: HttpStatus.CONFLICT,
error: 'Conflict',
message: 'Ресурс із такими даними вже існує',
code: 'RESOURCE_ALREADY_EXISTS',
},
};
}
if (exception.code === 'P2025') {
return {
status: HttpStatus.NOT_FOUND,
internalCode: exception.code,
body: {
statusCode: HttpStatus.NOT_FOUND,
error: 'Not Found',
message: 'Запитаний ресурс не знайдено',
code: 'RESOURCE_NOT_FOUND',
},
};
}
if (exception.code === 'P2003') {
return {
status: HttpStatus.CONFLICT,
internalCode: exception.code,
body: {
statusCode: HttpStatus.CONFLICT,
error: 'Conflict',
message: 'Операція конфліктує з пов’язаними даними',
code: 'RELATED_DATA_CONFLICT',
},
};
}
if (exception.code === 'P2000' || exception.code === 'P2011') {
return {
status: HttpStatus.BAD_REQUEST,
internalCode: exception.code,
body: {
statusCode: HttpStatus.BAD_REQUEST,
error: 'Bad Request',
message: 'Некоректні дані для збереження',
code: 'INVALID_DATABASE_VALUE',
},
};
}
if (exception.code === 'P2010') {
return this.mapPostgresError(meta.code);
}
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
internalCode: exception.code,
body: {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
error: 'Internal Server Error',
message: 'Не вдалося виконати операцію з базою даних',
code: 'DATABASE_ERROR',
},
};
}
if (exception instanceof Prisma.PrismaClientValidationError) {
return {
status: HttpStatus.BAD_REQUEST,
internalCode: 'PRISMA_VALIDATION_ERROR',
body: {
statusCode: HttpStatus.BAD_REQUEST,
error: 'Bad Request',
message: 'Некоректні дані запиту',
code: 'INVALID_DATA',
},
};
}
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
internalCode: 'PRISMA_UNKNOWN_ERROR',
body: {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
error: 'Internal Server Error',
message: 'Помилка бази даних',
code: 'DATABASE_ERROR',
},
};
}
private mapPostgresError(postgresCode?: string): {
status: number;
body: ErrorResponse;
internalCode: string;
postgresCode?: string;
} {
if (postgresCode === '23505') {
return {
status: HttpStatus.CONFLICT,
internalCode: 'P2010',
postgresCode,
body: {
statusCode: HttpStatus.CONFLICT,
error: 'Conflict',
message: 'Ресурс із такими даними вже існує',
code: 'RESOURCE_ALREADY_EXISTS',
},
};
}
if (postgresCode === '23503') {
return {
status: HttpStatus.CONFLICT,
internalCode: 'P2010',
postgresCode,
body: {
statusCode: HttpStatus.CONFLICT,
error: 'Conflict',
message: 'Операція конфліктує з пов’язаними даними',
code: 'RELATED_DATA_CONFLICT',
},
};
}
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
internalCode: 'P2010',
postgresCode,
body: {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
error: 'Internal Server Error',
message: 'Не вдалося виконати операцію з базою даних',
code: 'DATABASE_ERROR',
},
};
}
private getMeta(meta: unknown): PrismaMeta {
if (typeof meta !== 'object' || meta === null) {
return {};
}
return meta as PrismaMeta;
}
private writeLog(
exception: unknown,
request: Request,
result: {
internalCode: string;
postgresCode?: string;
body: ErrorResponse;
},
): void {
const logData = {
event: 'database_error',
method: request.method,
path: request.url,
internalCode: result.internalCode,
postgresCode: result.postgresCode,
responseCode: result.body.code,
exception:
exception instanceof Error ? exception.message : 'Unknown exception',
};
this.logger.error(
JSON.stringify(logData),
exception instanceof Error ? exception.stack : undefined,
);
}
}У цьому filter:
P2002 перетворюється на 409;
P2025 перетворюється на 404;
P2003 перетворюється на 409;
помилки значення перетворюються на 400;
невідомі помилки не розкривають технічні деталі;
у лог записуються Prisma-код, PostgreSQL-код і HTTP-метод;
клієнт отримує стабільний формат відповіді.
main.tsFilter можна підключити глобально під час запуску застосунку.
// src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new PrismaExceptionFilter());
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();Після цього filter застосовується до всіх Prisma-помилок у контролерах і сервісах застосунку.
Сервісу не потрібно знати, як саме помилка буде представлена через HTTP. Його відповідальність — виконати операцію та дозволити помилці піднятися до filter.
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
type CreateUserInput = {
email: string;
name: string;
};
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async create(input: CreateUserInput) {
return this.prisma.user.create({
data: {
email: input.email,
name: input.name,
},
});
}
async remove(id: number): Promise<void> {
await this.prisma.user.delete({
where: { id },
});
}
}Якщо email вже існує, Prisma викине P2002, а глобальний filter поверне:
{
"statusCode": 409,
"error": "Conflict",
"message": "Ресурс із такими даними вже існує",
"code": "RESOURCE_ALREADY_EXISTS"
}Якщо користувача з таким id немає, Prisma викине P2025, а клієнт отримає:
{
"statusCode": 404,
"error": "Not Found",
"message": "Запитаний ресурс не знайдено",
"code": "RESOURCE_NOT_FOUND"
}Логи повинні бути корисними для діагностики, але не повинні містити секрети.
До логів зазвичай варто додавати:
HTTP-метод;
URL або маршрут;
Prisma-код;
PostgreSQL SQLSTATE-код;
внутрішній код відповіді;
stack trace;
ідентифікатор запиту, якщо він використовується в застосунку.
Не слід логувати без додаткового захисту:
паролі;
токени;
повні заголовки авторизації;
персональні дані без потреби;
повні SQL-запити з параметрами користувача.
exception.message може містити назви таблиць і колонок. Це прийнятно для внутрішнього захищеного логу, але не для HTTP-відповіді.
Вибір рівня залежить від типу проблеми:
P2002 — часто очікувана помилка бізнес-сценарію, але її можна логувати як warn;
P2025 — також може бути очікуваною ситуацією;
недоступність PostgreSQL — error;
невідома помилка Prisma — error.
У простому прикладі всі помилки записуються через logger.error, щоб не втратити діагностичну інформацію. У production-системі рівень можна вибирати окремо для кожного коду.
Не кожна ситуація повинна бути представлена помилкою Prisma.
Наприклад, якщо застосунок має перевірити стан замовлення, краще явно перевірити бізнес-умову:
if (order.status !== 'DRAFT') {
throw new ConflictException('Замовлення вже не можна змінити');
}Prisma filter призначений для технічних помилок бази даних. Бізнес-правила повинні залишатися в сервісах і повертати відповідні HttpException.
Таке розділення дає зрозумілу структуру:
сервіс вирішує, чи дозволена операція;
Prisma гарантує обмеження схеми;
filter перетворює технічні помилки Prisma на HTTP-відповіді.
400Помилка 400 Bad Request означає, що клієнт надіслав некоректний запит. Нею не слід маскувати всі помилки бази.
Наприклад:
помилка унікальності — це 409 Conflict;
відсутній ресурс — це 404 Not Found;
недоступна база — це 500 або 503;
відсутня колонка після невдалої міграції — це 500.
Неправильне перетворення всіх помилок на 400 ускладнює моніторинг і приховує проблеми конфігурації.
Помилки підключення до бази даних відрізняються від PrismaClientKnownRequestError. Наприклад, PostgreSQL може бути тимчасово недоступним або з’єднання може розірватися під час запиту.
Такі помилки не повинні перетворюватися на повідомлення на кшталт:
connect ECONNREFUSED ...Клієнту потрібно повернути загальне повідомлення, а технічну причину записати в лог:
{
"statusCode": 503,
"error": "Service Unavailable",
"message": "Сервіс тимчасово недоступний",
"code": "DATABASE_UNAVAILABLE"
}Для цього в production-застосунках часто використовують окремий filter для PrismaClientInitializationError та помилок виконання запитів, які не є відомими Prisma-помилками. Важливо не змішувати його з обробкою P2002 та P2025: це різні класи проблем.
Якщо база недоступна під час старту застосунку, зазвичай краще не запускати застосунок у непрацездатному стані. Якщо з’єднання зникає під час роботи, відповідь 503 Service Unavailable точніше описує ситуацію, ніж 500.
Prisma автоматично відкочує інтерактивну транзакцію, якщо callback завершується помилкою.
await this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: input.email,
name: input.name,
},
});
await tx.profile.create({
data: {
userId: user.id,
displayName: user.name,
},
});
});Не потрібно ловити помилку всередині транзакції лише для того, щоб викинути її повторно:
await this.prisma.$transaction(async (tx) => {
try {
// Операції транзакції
} catch (error) {
throw error;
}
});Це не додає користі. Якщо помилка не обробляється на рівні бізнес-логіки, достатньо дозволити їй піднятися до глобального filter.
Якщо ж сервіс додає бізнес-контекст, помилку потрібно або перетворити на конкретну HttpException, або повторно викинути без втрати початкової причини:
try {
return await this.prisma.$transaction(async (tx) => {
// Операції транзакції
return tx.user.create({
data: input,
});
});
} catch (error) {
this.logger.error('Не вдалося створити користувача в транзакції');
throw error;
}exception.message клієнтуreturn response.status(500).json({
message: exception.message,
});Повідомлення може розкрити:
назву таблиці;
назву поля;
структуру схеми;
SQLSTATE;
внутрішню конфігурацію PostgreSQL.
Повідомлення слід записувати в лог, а клієнту повертати контрольований текст.
Якщо кожен сервіс має власний try/catch, відповіді швидко стають непослідовними. Централізований filter зменшує дублювання та гарантує однаковий формат API.
500P2002 і P2025 є передбачуваними ситуаціями, які мають зрозумілі HTTP-відповідники. Повернення 500 для них помилково описує проблему як несправність сервера.
400Це приховує серверні проблеми та створює неправильне уявлення, що клієнт винен у недоступності бази або помилці міграції.
Не слід передавати в лог весь об’єкт запиту або всі аргументи Prisma без фільтрації. Дані для логування потрібно формувати явно.
Текст повідомлення Prisma може змінитися між версіями. Для клієнтського API краще використовувати власні стабільні коди:
RESOURCE_ALREADY_EXISTS;
RESOURCE_NOT_FOUND;
RELATED_DATA_CONFLICT;
DATABASE_ERROR.
Для перевірки обробки помилок варто протестувати щонайменше такі сценарії:
Створення двох записів з однаковим унікальним значенням.
Видалення неіснуючого запису.
Операцію з пов’язаними даними, яка порушує зовнішній ключ.
Некоректне значення, що не проходить DTO-валідацію.
Тимчасову недоступність PostgreSQL.
Raw SQL із помилкою PostgreSQL, якщо застосунок його використовує.
Перевіряти потрібно не лише HTTP-статус, а й те, що:
відповідь не містить внутрішнього повідомлення Prisma;
поле code має стабільне значення;
лог містить достатньо даних для діагностики;
stack trace доступний у внутрішніх логах;
конфіденційні значення не потрапляють у лог.
Prisma-помилки не варто обробляти окремим try/catch у кожному сервісі.
Глобальний exception filter централізовано перетворює коди Prisma на HTTP-відповіді.
P2002 зазвичай відповідає 409 Conflict.
P2025 відповідає 404 Not Found.
P2003 зазвичай відповідає 409 Conflict.
P2010 може містити SQLSTATE-код PostgreSQL, який також потрібно мапити.
Технічні повідомлення слід залишати в логах, а не повертати клієнту.
API має використовувати стабільні власні коди помилок.
Бізнес-помилки та помилки бази даних потрібно розділяти.
Недоступність PostgreSQL — це окремий сценарій, для якого часто підходить 503 Service Unavailable.