← назад к разделу

REST API · PROBLEM DETAILS Фаза 15. REST API

Ошибки RFC 9457 - Problem Details на NestJS

TypeScript · NestJS · RFC 9457

Единый машиночитаемый формат ошибок с контекстом и нарушениями полей.

1. Problem Details стандартизирует тело

1 / 5

application/problem+json содержит стабильную категорию type, HTTP status, краткий title, detail и идентификатор конкретного instance.

application/problem+jsontype: urn:problem:order-not-foundstatus: 404 · title: Not Founddetail: order was not foundinstance: urn:uuid:...one shape for every API error

2. code управляет логикой клиента

2 / 5

ORDER_NOT_FOUND стабилен для программного ветвления, detail предназначен человеку. traceId связывает ответ с логами и трассировкой.

codeORDER_NOT_FOUNDclient logicdetailhuman explanationnot a branch keytraceIdlogs + tracesincident diagnostics

3. Один filter формирует ответ

3 / 5

Глобальный @Catch() преобразует HTTP и неожиданные исключения в единый Problem Details. Контроллеры не собирают ошибки вручную.

HttpExceptionUnknown error@Catch()map statusbuild problemproblem+jsonconsistent body

4. violations показывают поля

4 / 5

ValidationPipe exceptionFactory превращает ошибки DTO в список { field, message }. Клиент подсвечивает форму без разбора общей строки.

Invalid DTO400 Bad RequestexceptionFactoryfield errors → violationsviolationsemail: invalidage: too small

5. Доменная ошибка сохраняет смысл

5 / 5

OrderAlreadyPaid преобразуется в 409 и стабильный code. Внутренний stack trace и детали инфраструктуры не раскрываются клиенту.

OrderAlreadyPaidException mapping409 ConflictORDER_ALREADY_PAIDSafe responseno stack tracetraceId included

Когда 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 категории ошибки
statusHTTP-статус (дублирует код ответа)
titleКороткое название (обычно название HTTP-статуса)
detailПодробности для пользователя, можно на русском
instanceУникальный URN конкретного инцидента
traceIdID трассировки, чтобы найти ошибку в логах
codeUPPER_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" недопустим — теряется категория ошибки.

Что пощупать

Problem Details в практикуме remodov/marketplace-system-nodejs собирает одна функция problemOf: type, title, status, detail, instance и необязательная карта errors по полям. Тесты шага про команды смотрят именно в errors.price и errors.delta и различают 400 «запрос нельзя начать» от 409 «состояние не позволяет».

Код: problem.ts, change-api.spec.ts.

Сделаем сами

Ветка step-03-commands-and-errors.

Что почитать дальше