Опирается на правила: 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)
statusHTTP-статус (дублирует код ответа)
titleКороткое описание (название HTTP-статуса)
detailДля пользователя, может быть на русском
instanceУникальный URN конкретного инцидента
traceIdID трассировки из traceparent
codeUPPER_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-X2type: "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 Gonedeprecated-эндпоинт после Sunset
429 Too Many Requestsrate 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-X1application/problem+json
type: "about:blank"R-ERR-X2URL или urn:problem:<service>:<code>
HTTP-код вне списка (418, 422, 451)R-ERR-X3стандартный 400/404/409/500
Stack trace, SQL-запрос в detailR-ERR-X4traceId для cross-ref
Одна ошибка вместо всех violationsR-ERR-6все ошибки за один запрос
Дефолтный { statusCode, message, error } NestJSR-ERR-1глобальный HttpExceptionFilter
BadRequestException с массивом строк вместо violationsR-ERR-5exceptionFactory в ValidationPipe
PII в detail (email, телефон, паспорт)R-ERR-X4error code, общее сообщение

Куда дальше

  • node/headers.md — traceparenttraceId, кастомные заголовки.
  • 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 — v1v2 только при breaking change.
  • Error handling → Node — иерархия исключений, R-ERR-MAP-*.