Когда API возвращает ошибку, клиент должен понять: что случилось, почему и что с этим делать. Дефолтный NestJS отдаёт { statusCode, message, error } — это не даёт клиенту ничего, кроме числа и строки. Стандарт RFC 9457 описывает единый формат для ошибок любого REST-сервиса. Разберём, как его реализовать.
Что такое Problem Details
RFC 9457 — это просто договорённость о форме тела ошибки. Вместо собственного формата каждой команды — одно и то же JSON-поле на всех сервисах:
{
"type": "urn:problem:order-service:order-not-found",
"status": 404,
"title": "Not Found",
"detail": "Заказ #7a3f не найден",
"instance": "urn:uuid:9f2d6c22-8e6d-4c2a-9b41-6b9a5e2f6c10",
"traceId": "00-1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c-7a8b9c0d1e2f3a4b-01",
"code": "ORDER_NOT_FOUND"
}
| Поле | Назначение |
|---|---|
type | Стабильный URI или URN категории ошибки |
status | HTTP-статус (дублирует код ответа) |
title | Короткое название (обычно название HTTP-статуса) |
detail | Подробности для пользователя, можно на русском |
instance | Уникальный URN конкретного инцидента |
traceId | ID трассировки, чтобы найти ошибку в логах |
code | UPPER_SNAKE_CASE-код для программной логики на клиенте |
Content-Type при ошибке должен быть application/problem+json, а не обычный application/json.
Почему дефолтный NestJS не подходит
NestJS из коробки возвращает:
{ "statusCode": 404, "message": "Not found", "error": "Not Found" }
У клиента нет ни type, ни code, ни instance. Формат нестандартный, и понять что именно не нашлось — невозможно. Решение: один глобальный Exception Filter, который перехватывает все исключения и формирует правильное тело.
Глобальный Exception Filter
// src/common/filters/http-exception.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
import { randomUUID } from 'crypto';
export interface ProblemDetails {
type: string;
status: number;
title: string;
detail: string;
instance: string;
traceId?: string;
code: string;
violations?: Violation[];
}
export interface Violation {
field?: string;
message: string;
}
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const body = buildProblem(exception, status, req);
res
.status(status)
.header('Content-Type', 'application/problem+json')
.json(body);
}
}
function buildProblem(
exception: unknown,
status: number,
req: { headers: Record<string, string> },
): ProblemDetails {
const traceId = extractTraceId(req.headers['traceparent']);
if (exception instanceof HttpException) {
const response = exception.getResponse();
if (typeof response === 'object' && 'code' in response) {
return response as ProblemDetails;
}
}
return {
type: 'urn:problem:internal:internal-server-error',
status,
title: 'Internal Server Error',
detail: 'Произошла непредвиденная ошибка',
instance: `urn:uuid:${randomUUID()}`,
traceId,
code: 'INTERNAL_SERVER_ERROR',
};
}
function extractTraceId(traceparent?: string): string | undefined {
if (!traceparent) return undefined;
const parts = traceparent.split('-');
return parts.length >= 2 ? parts[1] : undefined;
}
Фильтр регистрируется в main.ts:
app.useGlobalFilters(new DomainExceptionFilter(), new HttpExceptionFilter());
Порядок важен: доменный фильтр проверяется первым (о нём ниже).
Хелпер sendProblem
Чтобы не собирать HttpException вручную каждый раз, делают маленький хелпер:
// src/common/filters/send-problem.ts
import { HttpException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { ProblemDetails, Violation } from './http-exception.filter';
interface ProblemOptions {
type: string;
status: number;
title: string;
detail: string;
code: string;
traceId?: string;
violations?: Violation[];
}
export function sendProblem(options: ProblemOptions): never {
const body: ProblemDetails = {
...options,
instance: `urn:uuid:${randomUUID()}`,
};
throw new HttpException(body, options.status);
}
Использование в коде:
const order = await this.orderRepository.findById(orderId);
if (!order) {
sendProblem({
type: 'urn:problem:order-service:order-not-found',
status: 404,
title: 'Not Found',
detail: `Заказ #${orderId} не найден`,
code: 'ORDER_NOT_FOUND',
});
}
Глобальный фильтр видит, что в теле есть поле code, и пропускает его без изменений.
Маппинг доменных исключений
Если приложение бросает собственные исключения (OrderNotFoundException, ProductArchivedError и т.п.), их не надо оборачивать в sendProblem внутри каждого сценария — это делает отдельный DomainExceptionFilter:
// src/common/filters/domain-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { randomUUID } from 'crypto';
import { OrderNotFoundException } from '../../order/domain/exceptions/order-not-found.exception';
import { ProductArchivedError } from '../../product/domain/exceptions/product-archived.error';
@Catch(OrderNotFoundException, ProductArchivedError)
export class DomainExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const { status, body } = resolve(exception);
res
.status(status)
.header('Content-Type', 'application/problem+json')
.json(body);
}
}
function resolve(exception: unknown): { status: number; body: object } {
const instance = `urn:uuid:${randomUUID()}`;
if (exception instanceof OrderNotFoundException) {
return {
status: HttpStatus.NOT_FOUND,
body: {
type: 'urn:problem:order-service:order-not-found',
status: 404,
title: 'Not Found',
detail: `Заказ #${exception.orderId} не найден`,
instance,
code: 'ORDER_NOT_FOUND',
},
};
}
if (exception instanceof ProductArchivedError) {
return {
status: HttpStatus.CONFLICT,
body: {
type: 'urn:problem:catalog:product-archived',
status: 409,
title: 'Conflict',
detail: `Товар ${exception.productId} снят с продажи`,
instance,
code: 'PRODUCT_ARCHIVED',
},
};
}
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
body: {
type: 'urn:problem:internal:internal-server-error',
status: 500,
title: 'Internal Server Error',
detail: 'Произошла непредвиденная ошибка',
instance,
code: 'INTERNAL_SERVER_ERROR',
},
};
}
Доменное исключение бросается в бизнес-логике, фильтр превращает его в Problem Details. Бизнес-логика ничего не знает о HTTP.
Ошибки валидации с violations
По умолчанию ValidationPipe бросает BadRequestException с массивом строк. Клиент не знает, к какому полю относится каждая ошибка. Решение — exceptionFactory, который формирует violations:
// src/main.ts (фрагмент)
import { ValidationPipe, HttpStatus, HttpException } from '@nestjs/common';
import { ValidationError } from 'class-validator';
import { randomUUID } from 'crypto';
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
exceptionFactory: (errors: ValidationError[]) => {
const violations = flattenViolations(errors);
return new HttpException(
{
type: 'urn:problem:order-service:validation-error',
status: HttpStatus.BAD_REQUEST,
title: 'Bad Request',
detail: 'Ошибка валидации входных данных',
instance: `urn:uuid:${randomUUID()}`,
code: 'VALIDATION_ERROR',
violations,
},
HttpStatus.BAD_REQUEST,
);
},
}),
);
function flattenViolations(
errors: ValidationError[],
prefix = '',
): { field: string; message: string }[] {
return errors.flatMap((error) => {
const field = prefix ? `${prefix}.${error.property}` : error.property;
const messages = Object.values(error.constraints ?? {}).map((message) => ({
field,
message,
}));
const nested = error.children?.length
? flattenViolations(error.children, field)
: [];
return [...messages, ...nested];
});
}
Результат при невалидном запросе:
{
"type": "urn:problem:order-service:validation-error",
"status": 400,
"title": "Bad Request",
"detail": "Ошибка валидации входных данных",
"instance": "urn:uuid:2c4d6e22-1a3b-4f5c-9d8e-7b0a1c2d3e4f",
"code": "VALIDATION_ERROR",
"violations": [
{ "field": "customerId", "message": "customerId must be a UUID" },
{ "field": "deliveryAddress.zipCode", "message": "zipCode should not be empty" },
{ "field": "items[0].quantity", "message": "quantity must not be less than 1" }
]
}
Поле field — в dot-notation с индексами для массивов. Все ошибки возвращаются за один запрос, а не по одной.
Поле type — URI или URN
type должен быть стабильным — меняется только если меняется смысл ошибки. Два подхода:
URL на страницу документации — если есть developer-портал:
"type": "https://errors.example.com/order/not-found"
URN без портала — простой и надёжный вариант:
"type": "urn:problem:order-service:order-not-found"
"type": "urn:problem:catalog:product-archived"
Значение "about:blank" использовать нельзя: клиент лишается категории ошибки и не может ветвиться по type.
Поле code и клиентская логика
Клиент должен ветвиться по code, а не по status. HTTP-статус слишком грубый: 409 может значить и конфликт версий, и превышение лимита. code точный:
switch (error.code) {
case 'ORDER_NOT_FOUND':
router.push('/orders');
break;
case 'VALIDATION_ERROR':
highlightFields(error.violations);
break;
case 'EXT_SYSTEM_UNAVAILABLE':
showRetryButton();
break;
}
Все коды перечисляют в OpenAPI как enum:
export enum ErrorCode {
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
VALIDATION_ERROR = 'VALIDATION_ERROR',
ORDER_NOT_FOUND = 'ORDER_NOT_FOUND',
PRODUCT_ARCHIVED = 'PRODUCT_ARCHIVED',
INSUFFICIENT_STOCK = 'INSUFFICIENT_STOCK',
CUSTOMER_LIMIT_EXCEEDED = 'CUSTOMER_LIMIT_EXCEEDED',
}
Какой HTTP-статус использовать
| Код | Когда |
|---|---|
400 Bad Request | ошибка валидации, неверный формат тела |
401 Unauthorized | нет токена или токен истёк |
403 Forbidden | авторизация отказала |
404 Not Found | обращение к несуществующему объекту |
409 Conflict | конкурентное изменение, дубликат, бизнес-конфликт |
410 Gone | устаревший эндпоинт после отключения |
429 Too Many Requests | превышен лимит запросов |
500 Internal Server Error | непредвиденные исключения |
Нестандартные коды (418, 422, 451) лучше не использовать без явной необходимости.
Частые ошибки
Content-Type оставили application/json — клиент не узнает, что это Problem Details. Нужен application/problem+json.
Дефолтный формат NestJS ({ statusCode, message, error }) — без глобального фильтра он пробивается для некоторых исключений. Всегда регистрируйте HttpExceptionFilter глобально.
Stack trace или SQL-запрос в поле detail — никогда не включайте внутренние детали в ответ. Для отладки достаточно traceId, по которому находят логи.
Одна ошибка валидации вместо всех — если в форме три невалидных поля, клиент должен получить все три сразу. flattenViolations делает именно это.
PII в detail — email, телефон, номер документа не должны попадать в тело ошибки. Используйте code и общее описание.
Коротко
- RFC 9457 — стандарт формата ошибок:
type,status,title,detail,instance,code. - Content-Type для ошибок —
application/problem+json, неapplication/json. - Один глобальный
HttpExceptionFilterперехватывает все исключения и формирует правильное тело. - Хелпер
sendProblemупрощает создание типизированных ошибок в сценариях. DomainExceptionFilterпревращает доменные исключения в Problem Details без HTTP-зависимости в бизнес-логике.exceptionFactoryвValidationPipeдаёт полный список нарушений сfieldв dot-notation.- Клиент ветвится по
code, а не поstatus— код точнее HTTP-статуса. type: "about:blank"недопустим — теряется категория ошибки.
Что почитать дальше
- Заголовки и трассировка в NestJS —
traceparent→traceId, кастомные заголовки. - JSON и формат ответов в NestJS — формат 2xx, пагинация.
- URL и ресурсы в NestJS — kebab-case, URI-versioning.
- OpenAPI и антипаттерны в NestJS —
operationId,@ApiTags, генерация спецификации.