Опирается на правила:
R-ERR-MAP-1…R-ERR-MAP-5иR-ERR-MAP-X1…R-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-X2 | stacktrace только в логи, в detail — человекочитаемая фраза + traceId |
detail: e.response?.data?.message без санитизации | R-ERR-MAP-X3 | Фиксированная фраза + traceId; детали внешки — в логи |
Content-Type: application/json на error-response | — | Content-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.