Опирается на правила:
R-ERR-1..9,R-ERR-X1..X4из REST API Style Guide → раздел Ошибки RFC 9457.
Важно знать
- Тело ошибки соответствует RFC 9457 Problem Details:
type,status,title,detail,instance,traceId,code.Content-Type: application/problem+jsonобязателен — дефолтный NestJS отдаётapplication/json(R-ERR-X1).- Дефолтный формат NestJS (
{ statusCode, message, error }) не соответствует RFC 9457 — нужен глобальныйHttpExceptionFilter.ValidationPipeпо умолчанию бросаетBadRequestExceptionбезviolations— заменить черезexceptionFactory.code— UPPER_SNAKE_CASE enum; все коды перечислены в OpenAPI; клиент ветвится поcode, не поtype.type: "about:blank"— запрещено (R-ERR-X2); URN формыurn:problem:<service>:<code>или URL на документацию.violationsдляVALIDATION_ERROR:fieldв dot-notation с индексами (items[0].quantity), все ошибки за один запрос.500:traceIdдля cross-system отладки, без stack traces, SQL-запросов и внутренних путей (R-ERR-X4).
NestJS предоставляет встроенную систему Exception Filters, однако её дефолтный формат ответа несовместим с RFC 9457. Весь маппинг — в одном глобальном фильтре; application-код бросает типизированные исключения, не HttpException напрямую.
Структура Problem Details
R-ERR-1 — обязательный контракт ответа при любой ошибке 4xx/5xx:
{
"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 категории ошибки (R-ERR-2) |
status | HTTP-статус (дублирует код ответа) |
title | Короткое описание (название HTTP-статуса) |
detail | Для пользователя, может быть на русском |
instance | Уникальный URN конкретного инцидента |
traceId | ID трассировки из traceparent |
code | UPPER_SNAKE_CASE enum для программной логики |
Глобальный Exception Filter
Центральная точка — HttpExceptionFilter, регистрируется как глобальный в main.ts.
// 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 HttpExceptionFilter());
Хелпер sendProblem
Хелпер формирует типизированный HttpException с ProblemDetails-телом — фильтр пропускает его без изменений:
// 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);
}
Пример использования в UseCase:
// src/order/use-cases/get-order.use-case.ts
import { Injectable } from '@nestjs/common';
import { OrderRepository } from '../ports/order.repository';
import { sendProblem } from '../../common/filters/send-problem';
@Injectable()
export class GetOrderUseCase {
constructor(private readonly orderRepository: OrderRepository) {}
async execute(orderId: string): Promise<OrderDto> {
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',
});
}
return toOrderDto(order);
}
}
Маппинг доменных исключений
Для систематического маппинга — выделенный DomainExceptionFilter, работающий поверх HttpExceptionFilter:
// 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';
import { CustomerLimitExceededError } from '../../customer/domain/exceptions/customer-limit-exceeded.error';
import { InsufficientStockError } from '../../product/domain/exceptions/insufficient-stock.error';
@Catch(
OrderNotFoundException,
ProductArchivedError,
CustomerLimitExceededError,
InsufficientStockError,
)
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',
},
};
}
if (exception instanceof CustomerLimitExceededError) {
return {
status: HttpStatus.CONFLICT,
body: {
type: 'urn:problem:order-service:customer-limit-exceeded',
status: 409,
title: 'Conflict',
detail: 'Превышен месячный лимит заказов',
instance,
code: 'CUSTOMER_LIMIT_EXCEEDED',
},
};
}
if (exception instanceof InsufficientStockError) {
return {
status: HttpStatus.CONFLICT,
body: {
type: 'urn:problem:catalog:insufficient-stock',
status: 409,
title: 'Conflict',
detail: `Недостаточно остатков по товару ${exception.productId}`,
instance,
code: 'INSUFFICIENT_STOCK',
},
};
}
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',
},
};
}
Регистрация — порядок важен: доменный фильтр проверяется первым:
app.useGlobalFilters(new HttpExceptionFilter(), new DomainExceptionFilter());
Валидация — exceptionFactory и violations
Дефолтный ValidationPipe бросает BadRequestException с массивом строк — это не соответствует R-ERR-5..6. Подменяем через exceptionFactory:
// src/main.ts (фрагмент)
import { ValidationPipe, HttpStatus } from '@nestjs/common';
import { ValidationError } from 'class-validator';
import { randomUUID } from 'crypto';
import { sendProblem } from './common/filters/send-problem';
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
exceptionFactory: (errors: ValidationError[]) => {
const violations = flattenViolations(errors);
throw sendProblem({
type: 'urn:problem:order-service:validation-error',
status: HttpStatus.BAD_REQUEST,
title: 'Bad Request',
detail: 'Ошибка валидации входных данных',
code: 'VALIDATION_ERROR',
violations,
});
},
}),
);
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];
});
}
Ответ при невалидном POST /api/v1/orders:
{
"type": "urn:problem:order-service:validation-error",
"status": 400,
"title": "Bad Request",
"detail": "Ошибка валидации входных данных",
"instance": "urn:uuid:2c4d6e22-1a3b-4f5c-9d8e-7b0a1c2d3e4f",
"traceId": "00-4a3b2c1d0e9f8a7b-6c5d4e3f2a1b0c9d-01",
"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" }
]
}
DTO с декораторами валидации:
// src/order/dto/create-order.dto.ts
import { Type } from 'class-transformer';
import {
IsArray,
IsNotEmpty,
IsPositive,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class DeliveryAddressDto {
@ApiProperty()
@IsNotEmpty()
@IsString()
city: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
zipCode: string;
}
export class OrderItemDto {
@ApiProperty()
@IsUUID()
productId: string;
@ApiProperty({ minimum: 1 })
@IsPositive()
@Min(1)
quantity: number;
}
export class CreateOrderDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
customerId: string;
@ApiProperty({ type: DeliveryAddressDto })
@ValidateNested()
@Type(() => DeliveryAddressDto)
deliveryAddress: DeliveryAddressDto;
@ApiProperty({ type: [OrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}
type — URI или URN
R-ERR-2: одна категория — один стабильный type.
Вариант 1 — URL на страницу документации:
"type": "https://errors.sber.ru/api/order/not-found"
"type": "https://developer.sber.ru/errors/insufficient-stock"
Страница описывает причину и способ исправления. Актуально при наличии developer-портала.
Вариант 2 — URN без портала:
"type": "urn:problem:order-service:order-not-found"
"type": "urn:problem:catalog:product-archived"
"type": "urn:problem:customer-service:customer-limit-exceeded"
URN сохраняет машиночитаемую категорию без необходимости разворачивать портал документации.
R-ERR-X2 — type: "about:blank" запрещён: теряется категория ошибки, клиент не может ветвиться.
code — enum в OpenAPI
R-ERR-4: все коды перечислены в OpenAPI как enum. NestJS + @nestjs/swagger — через класс-DTO или явное указание в схеме:
// src/common/dto/problem-details.dto.ts
import { ApiProperty } from '@nestjs/swagger';
export enum ErrorCode {
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
VALIDATION_ERROR = 'VALIDATION_ERROR',
ORDER_NOT_FOUND = 'ORDER_NOT_FOUND',
ORDER_EMPTY = 'ORDER_EMPTY',
PRODUCT_ARCHIVED = 'PRODUCT_ARCHIVED',
INSUFFICIENT_STOCK = 'INSUFFICIENT_STOCK',
CUSTOMER_LIMIT_EXCEEDED = 'CUSTOMER_LIMIT_EXCEEDED',
EXT_SYSTEM_UNAVAILABLE = 'EXT_SYSTEM_UNAVAILABLE',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
}
export class ViolationDto {
@ApiProperty({ required: false })
field?: string;
@ApiProperty()
message: string;
}
export class ProblemDetailsDto {
@ApiProperty({ format: 'uri' })
type: string;
@ApiProperty()
status: number;
@ApiProperty()
title: string;
@ApiProperty()
detail: string;
@ApiProperty({ format: 'uri' })
instance: string;
@ApiProperty({ required: false })
traceId?: string;
@ApiProperty({ enum: ErrorCode })
code: ErrorCode;
@ApiProperty({ type: [ViolationDto], required: false })
violations?: ViolationDto[];
}
Клиент ветвится по code, не по status:
switch (error.code) {
case 'ORDER_NOT_FOUND':
router.push('/orders');
break;
case 'VALIDATION_ERROR':
highlightFields(error.violations);
break;
case 'EXT_SYSTEM_UNAVAILABLE':
showRetryButton();
break;
}
HTTP-коды
R-ERR-9:
| Код | Когда |
|---|---|
400 Bad Request | валидация, malformed body |
401 Unauthorized | нет токена, токен истёк |
403 Forbidden | авторизация отказала (RBAC/ABAC) |
404 Not Found | обращение к объекту по ID |
409 Conflict | конкурентное изменение, дубликат |
410 Gone | deprecated-эндпоинт после Sunset |
429 Too Many Requests | rate limit превышен |
500 Internal Server Error | непредвиденные исключения |
R-ERR-X3 — HTTP-коды вне списка (418, 422, 451) запрещены без согласования с архитектурным комитетом.
OpenAPI-метаданные ответов с ошибками
R-ERR-7..8 — контроллер декларирует examples для каждого возможного кода ошибки:
// src/order/controllers/order.controller.ts (фрагмент)
import {
ApiNotFoundResponse,
ApiConflictResponse,
ApiBadRequestResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { ProblemDetailsDto } from '../../common/dto/problem-details.dto';
@ApiTags('Orders')
@Controller('orders')
export class OrderController {
@Post()
@ApiOperation({ operationId: 'createOrder', summary: 'Создать заказ' })
@ApiBadRequestResponse({ type: ProblemDetailsDto, description: 'VALIDATION_ERROR' })
@ApiConflictResponse({ type: ProblemDetailsDto, description: 'PRODUCT_ARCHIVED | INSUFFICIENT_STOCK' })
async createOrder(@Body() dto: CreateOrderDto): Promise<OrderDto> { /* ... */ }
@Get(':orderId')
@ApiOperation({ operationId: 'getOrder', summary: 'Получить заказ по ID' })
@ApiNotFoundResponse({ type: ProblemDetailsDto, description: 'ORDER_NOT_FOUND' })
async getOrder(@Param('orderId') orderId: string): Promise<OrderDto> { /* ... */ }
}
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Content-Type: application/json для ошибки | R-ERR-X1 | application/problem+json |
type: "about:blank" | R-ERR-X2 | URL или urn:problem:<service>:<code> |
| HTTP-код вне списка (418, 422, 451) | R-ERR-X3 | стандартный 400/404/409/500 |
Stack trace, SQL-запрос в detail | R-ERR-X4 | traceId для cross-ref |
Одна ошибка вместо всех violations | R-ERR-6 | все ошибки за один запрос |
Дефолтный { statusCode, message, error } NestJS | R-ERR-1 | глобальный HttpExceptionFilter |
BadRequestException с массивом строк вместо violations | R-ERR-5 | exceptionFactory в ValidationPipe |
PII в detail (email, телефон, паспорт) | R-ERR-X4 | error code, общее сообщение |
Куда дальше
- node/headers.md —
traceparent→traceId, кастомные заголовки. - node/json-and-responses.md — формат 2xx,
undefinedвместоnull,content-пагинация. - node/url-and-resources.md — kebab-case, URI-versioning,
@HttpCode. - node/rate-limiting-files-deprecation.md — 429 +
Retry-After, 410 Gone. - node/openapi-and-antipatterns.md —
operationId,@ApiTags, генерация спеки. - node/versioning.md —
v1→v2только при breaking change. - Error handling → Node — иерархия исключений,
R-ERR-MAP-*.