Опирается на правила: R-ERR-MAP-1R-ERR-MAP-5 и R-ERR-MAP-X1R-ERR-MAP-X3 из Error Handling Style Guide → раздел 3. Mapping в problem+json.

Важно знать

  • NestJS не генерирует application/problem+json автоматически — формат строится вручную в Exception Filters через хелпер sendProblem.
  • DomainError → 409 / 422, type = URL на карточку ошибки в docs/spec/errors/, extension-поля с контекстом.
  • InputValidationError → 400 с errors-массивом per-field. ValidationPipe оборачивается через exceptionFactory.
  • IntegrationError → 502 / 503 / 504. Сырое тело внешки в detail не вкладывать (PII) — только фраза + traceId.
  • TechnicalError → 500, минимум в response, детали — в логи.
  • Catch-all → 500, ERROR-лог + полный stacktrace + контекст.
  • { "success": false } в теле 200 — антипаттерн: мониторинг прозевает (всё не-4xx/5xx считается успехом).
  • stacktrace в detail — утечка путей/версий/классов; только в логи.

NestJS не реализует RFC 9457 из коробки. По умолчанию HttpException возвращает JSON вида { "statusCode": 422, "message": "..." } — это не ProblemDetails. Формат строится в Exception Filters: один хелпер sendProblem, per-type фильтры, правильные Content-Type. Раскрытие правил R-ERR-MAP-* ниже.

Хелпер sendProblem

Единый хелпер для всех фильтров — не дублировать формат вручную:

// edge/problem.ts
import { Response } from 'express';
import { getTraceId } from '../telemetry/trace-context';

export interface ProblemDetails {
  type?: string;
  title: string;
  status: number;
  detail: string;
  traceId: string;
  [key: string]: unknown;
}

export function sendProblem(
  res: Response,
  status: number,
  title: string,
  detail: string,
  ext: Record<string, unknown> = {},
): void {
  const body: ProblemDetails = {
    type: 'about:blank',
    title,
    status,
    detail,
    traceId: getTraceId(),
    ...ext,
  };
  res.status(status).type('application/problem+json').json(body);
}

getTraceId() — извлекает trace-id из OpenTelemetry context или AsyncLocalStorage. Клиент получает traceId для обращения в поддержку; оператор по нему находит полный лог-trace.

DomainError → 409 / 422

R-ERR-MAP-1: нарушение текущего состояния → 409, нарушение инвариантов / бизнес-правил → 422.

// edge/filters/domain-error.filter.ts
@Catch(DomainError)
export class DomainErrorFilter implements ExceptionFilter {
  private readonly logger = new Logger(DomainErrorFilter.name);

  catch(err: DomainError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    this.logger.warn(`domain rule violated: ${err.message}`);
    appErrorsTotal.inc({ type: 'domain', exception: err.name });

    const status = err instanceof ConflictDomainError ? 409 : 422;
    sendProblem(res, status, 'Operation cannot be completed', err.message, {
      type: `https://api.example.com/errors/${toKebabCase(err.name)}`,
    });
  }
}

Специфичные наследники получают собственные фильтры с типизированным доступом к полям:

// edge/filters/insufficient-funds.filter.ts
@Catch(InsufficientFundsError)
export class InsufficientFundsFilter implements ExceptionFilter {
  catch(err: InsufficientFundsError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    sendProblem(res, 422, 'Insufficient funds', 'Account balance is not enough', {
      type: 'https://api.example.com/errors/insufficient-funds',
      customerId: err.customerId,
      requested: err.requested.toString(),
      available: err.available.toString(),
    });
  }
}

// edge/filters/order-already-shipped.filter.ts
@Catch(OrderAlreadyShippedError)
export class OrderAlreadyShippedFilter implements ExceptionFilter {
  catch(err: OrderAlreadyShippedError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    sendProblem(res, 409, 'Order already shipped',
      'This order has already been shipped and cannot be modified', {
        type: 'https://api.example.com/errors/order-already-shipped',
        orderId: err.orderId,
      });
  }
}

type — это URL на карточку ошибки в docs/spec/errors/. Клиент может открыть URL и прочитать, что означает эта ошибка, как её обработать. Не произвольная строка — документированный контракт.

Деньги (bigint) в extension-полях сериализуем как строку — JSON.stringify не сохраняет точность bigint.

InputValidationError → 400

R-ERR-MAP-2: валидация → 400 с errors-массивом per-field.

NestJS ValidationPipe из коробки кидает BadRequestException с форматом, не совместимым с ProblemDetails. Исправляем через exceptionFactory:

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    exceptionFactory: (validationErrors) => {
      const errors = flattenValidationErrors(validationErrors);
      return new InputValidationError(errors);
    },
  }),
);

// edge/validation.utils.ts
export interface ValidationErrorItem {
  field: string;
  message: string;
  value?: unknown;
}

export function flattenValidationErrors(
  errors: ValidationError[],
  parent = '',
): ValidationErrorItem[] {
  return errors.flatMap((err) => {
    const field = parent ? `${parent}.${err.property}` : err.property;
    const messages = Object.values(err.constraints ?? {}).map((msg) => ({
      field,
      message: msg,
    }));
    const nested = err.children?.length
      ? flattenValidationErrors(err.children, field)
      : [];
    return [...messages, ...nested];
  });
}
// edge/filters/validation.filter.ts
@Catch(InputValidationError)
export class ValidationFilter implements ExceptionFilter {
  catch(err: InputValidationError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    appErrorsTotal.inc({ type: 'validation', exception: err.name });
    res.status(400).type('application/problem+json').json({
      type: 'https://api.example.com/errors/validation-failed',
      title: 'Validation Failed',
      status: 400,
      detail: 'One or more fields did not pass validation',
      traceId: getTraceId(),
      errors: err.errors,
    });
  }
}

Пример ответа для создания заказа с неверными полями:

{
  "type": "https://api.example.com/errors/validation-failed",
  "title": "Validation Failed",
  "status": 400,
  "detail": "One or more fields did not pass validation",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "errors": [
    { "field": "items[0].quantity", "message": "quantity must be a positive number" },
    { "field": "customerId", "message": "customerId must be a UUID" }
  ]
}

IntegrationError → 502 / 503 / 504

R-ERR-MAP-3: внешка 5xx → 502, circuit breaker открыт / bulkhead reject → 503, timeout → 504.

// edge/filters/payment-gateway.filter.ts
@Catch(PaymentGatewayError)
export class PaymentGatewayErrorFilter implements ExceptionFilter {
  private readonly logger = new Logger(PaymentGatewayErrorFilter.name);

  catch(err: PaymentGatewayError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    this.logger.warn(`payment gateway error: ${err.message}`);
    appErrorsTotal.inc({ type: 'integration', exception: err.name });
    sendProblem(res, 502, 'Payment service unavailable',
      `Payment system error. traceId: ${getTraceId()}`);
  }
}

@Catch(PaymentGatewayUnavailableError)
export class PaymentGatewayUnavailableFilter implements ExceptionFilter {
  private readonly logger = new Logger(PaymentGatewayUnavailableFilter.name);

  catch(err: PaymentGatewayUnavailableError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    this.logger.error(`payment gateway circuit breaker open: ${err.message}`);
    appErrorsTotal.inc({ type: 'integration', exception: err.name });
    sendProblem(res, 503, 'Payment service temporarily unavailable',
      `Payment system is temporarily unavailable. traceId: ${getTraceId()}`, {
        retryAfter: 30,
      });
  }
}

Что не вкладывать в detail:

// ПЛОХО — сырое тело внешки в detail
sendProblem(res, 502, 'Payment error',
  e.response?.data?.message ?? e.message);   // может содержать PII, схему БД внешки, трейс внешки

// PREFER
sendProblem(res, 502, 'Payment service unavailable',
  `Payment system error. traceId: ${getTraceId()}`);   // только фраза + traceId

Для timeout добавляем Retry-After заголовок:

res.set('Retry-After', '30');
sendProblem(res, 504, 'Payment service timeout',
  `Request to payment system timed out. traceId: ${getTraceId()}`);

TechnicalError → 500

R-ERR-MAP-4: техническая ошибка — минимум в response, детали — в логи.

// edge/filters/technical-error.filter.ts
@Catch(TechnicalError)
export class TechnicalErrorFilter implements ExceptionFilter {
  private readonly logger = new Logger(TechnicalErrorFilter.name);

  catch(err: TechnicalError, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    this.logger.error('technical error', err.stack);
    appErrorsTotal.inc({ type: 'technical', exception: err.name });
    sendProblem(res, 500, 'Internal Server Error',
      `An internal error occurred. traceId: ${getTraceId()}`);
  }
}

В detail — только «internal error» + traceId. Текст сообщения из err.message не идёт в response — может содержать имена таблиц, SQL-запросы, внутренние пути.

Catch-all → 500

R-ERR-MAP-5: всё непредвиденное — ERROR-лог + stacktrace + 500.

// edge/filters/unexpected.filter.ts
@Catch()
export class UnexpectedFilter implements ExceptionFilter {
  private readonly logger = new Logger(UnexpectedFilter.name);

  catch(err: unknown, host: ArgumentsHost): void {
    const res = host.switchToHttp().getResponse<Response>();
    const name = err instanceof Error ? err.name : 'Unknown';
    const stack = err instanceof Error ? err.stack : String(err);
    this.logger.error(`unexpected error [${name}]: ${stack}`);
    appErrorsTotal.inc({ type: 'unexpected', exception: name });
    sendProblem(res, 500, 'Internal Server Error',
      `An unexpected error occurred. traceId: ${getTraceId()}`);
  }
}

Каждый unexpected — сигнал бага. Создаётся issue в трекере (вручную или через алёрт в Prometheus — см. Observability).

Что запрещено

АнтипаттернПравилоЧто взамен
res.status(200).json({ success: false, error: '...' })R-ERR-MAP-X1Корректный HTTP-статус 4xx/5xx + application/problem+json
detail: err.stack / stacktrace в теле ответаR-ERR-MAP-X2stacktrace только в логи, в detail — человекочитаемая фраза + traceId
detail: e.response?.data?.message без санитизацииR-ERR-MAP-X3Фиксированная фраза + traceId; детали внешки — в логи
Content-Type: application/json на error-responseContent-Type: application/problem+json (RFC 9457)
Разные форматы ошибок в разных контроллерахЕдиный хелпер sendProblem во всех фильтрах
BigInt в JSON-теле ответа без .toString()err.requested.toString() / String(err.available) в extension-полях

Куда дальше

  • Иерархия исключений — типы, которые маппируются.
  • Где throw, где catch — как исключения доходят до фильтров.
  • Логирование исключений — log-level в фильтрах.
  • Retry-семантика — 4xx от внешки → DomainError, 5xx → IntegrationError.
  • Observability — метрики и трейсинг в фильтрах.
  • Error Handling Style Guide (Node) — нормативный индекс правил.
  • REST API Style Guide → R-API-ERR-* — контракт error-response на уровне API.