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

Когда 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" недопустим — теряется категория ошибки.

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

  • Заголовки и трассировка в NestJS — traceparenttraceId, кастомные заголовки.
  • JSON и формат ответов в NestJS — формат 2xx, пагинация.
  • URL и ресурсы в NestJS — kebab-case, URI-versioning.
  • OpenAPI и антипаттерны в NestJS — operationId, @ApiTags, генерация спецификации.