Фильтр исключений и единый ответ отказа
Guard, ValidationPipe и repository могут отказать на разных стадиях. Клиенту нужен стабильный способ понять, произошло ли отсутствие удостоверения, неверное поле, неизвестная заявка или конфликт версии. Добавим ApiErrors — глобальный exception filter. Результат главы — единая форма ошибки с сохранённым HTTP-статусом и requestId. Неожиданное исключение даст обобщённый 500, а внутренний SQL или credentials не окажутся в пользовательском message.
Фильтр не должен превращать любой отказ в 200 с полем success false. HTTP-статус остаётся частью договора и уже используется Angular errorMessage. Если status скрыть, HttpClient может направить тело в успешный readTicket и получить ошибку модели вместо понятного 403. Поэтому envelope дополняет статус, а не заменяет его. Server code выражает стабильную машинную причину, message — человекочитаемое пояснение, issues — подробности некорректных входных полей.
Общая граница ошибки
Ниже полный ApiErrors из составного http.ts. ArgumentsHost переключается на HTTP, чтобы получить request/response. Catch без конкретного класса охватывает и HttpException, и unexpected errors. AppModule регистрирует provider через APP_FILTER. RequestTrace уже назначил id для прошедшего handler; если отказ возник раньше, filter создаёт fallback randomUUID и сам ставит X-Request-Id. Так ошибка guard получает допустимую идентификацию, даже не достигая interceptor.
@Catch()
export class ApiErrors implements ExceptionFilter {
private readonly logger = new Logger(ApiErrors.name);
catch(error: unknown, host: ArgumentsHost) {
const req = host.switchToHttp().getRequest<ActorRequest>(); const res = host.switchToHttp().getResponse<Response>();
const requestId = req.requestId ?? randomUUID(); res.setHeader('X-Request-Id',requestId);
const statusCode = error instanceof HttpException ? error.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const payload = error instanceof HttpException ? error.getResponse() : null;
const raw = payload && typeof payload === 'object' ? (payload as {message?:unknown}).message : payload;
const issues = Array.isArray(raw) ? raw.filter((value): value is string => typeof value === 'string') : undefined;
const message = statusCode >= 500 ? 'Внутренняя ошибка сервера' : issues ? 'Некорректные поля запроса' : typeof raw === 'string' ? raw : 'Запрос отклонён';
const codes: Record<number,string> = {400:'BAD_REQUEST',401:'UNAUTHORIZED',403:'FORBIDDEN',404:'NOT_FOUND',409:'VERSION_CONFLICT'};
if (statusCode >= 500) this.logger.error(`${requestId}: внутренняя ошибка ${error instanceof Error ? error.name : 'unknown'}`);
res.status(statusCode).json({statusCode,code:codes[statusCode] ?? 'INTERNAL_ERROR',message,requestId,...(issues ? {issues} : {})});
}
}
HttpException предоставляет свой status. ValidationPipe обычно передаёт массив строк в message, поэтому filter собирает issues и заменяет общий текст на «Некорректные поля запроса». NotFoundException и ConflictException дают строковое пояснение. Unexpected error получает 500 и нейтральный message. Пользователю не требуется имя SQL-таблицы, параметры соединения или stack для следующего действия. Внутренний logger также пишет только id и имя типа, не сериализуя весь объект ошибки с неизвестными полями.
Ожидаемый устаревшая version даст statusCode409, code VERSION_CONFLICT, message и requestId. Неверный title даст 400 с BAD_REQUEST и issues. Viewer PATCH — 403, неизвестный token — 401, отсутствующий rq-id — 404. Эти формы одинаковы по структуре, но не одинаковы по действию пользователя. Клиент сохранит черновик после 409, объяснит роль после 403 и предложит повтор чтения после транспортного сбоя. Одинаковый envelope не означает универсальный текст «Попробуйте ещё раз».
Стабильность и содержание
Code формируется из известного набора статусов лаборатории. Такой mapping упрощён: сейчас любой 409 означает конфликт версии, а 400 — некорректный запрос. Если позже появятся другие конфликтные причины, придётся определить отдельные domain codes и передавать их осознанно. Не следует без анализа расширять service новыми 409 и оставлять клиенту прежнее сообщение о version. Стабильный машинный договор требует согласования причин, а не только существования строкового поля code.
Message не используется как ключ протокола. Русскую формулировку можно улучшить без изменения действия клиента. Issues служат подробностям validation, но текущий Angular показывает общее серверное сообщение, не распределяя issues по FormControl. Для полноценного поля server-errors понадобится структурированная карта имён и правил, а не разбор свободного английского текста decorators. Лаборатория не делает такой разбор и поэтому не обещает, что любой issue автоматически окажется рядом с нужным input.
Unexpected500 скрывает сырой payload и raw exception. Это помогает не публиковать внутреннюю схему или случайные credentials. Но filter не может отозвать уже написанный другой logger или предотвратить все ошибки процесса до создания HTTP-приложения. Неправильная конфигурация bootstrap выводится верхним catch main, а отказ сетевого соединения клиента может вообще не получить response. Границы исполнения важны: один HTTP-filter не является универсальной обработкой всех событий Node и внешней инфраструктуры.
Жизненный цикл ответа
Filter использует ручной response.status(...).json(...) только для ошибки. Controller успеха остаётся framework-managed. Не смешивайте два способа в одном обработчике без отдельной причины. Если response уже отправлен другим участком, повторная попытка изменить status/header может быть невозможна. Текущие routes не используют streaming или ранний res.write, поэтому лаборатория имеет простую форму ответа. Для потокового API фильтр потребовал бы отдельного анализа момента, когда заголовки и данные уже покинули процесс.
RequestId в header и body совпадает для фильтра. Ранний 401 получает новый id здесь, а дошедший до handler404 сохраняет id interceptor. Это даёт человеку возможность сопоставить отказ с допустимой диагностикой. Но уникальный id не гарантирует доставку log или сохранение audit event. Он также не делает PATCH идемпотентным. Назначение поля узкое: идентичность операции для объяснения и расследования, а не ключ предметного состояния или автоматического восстановления записи.
Рассмотрите конфликт, после которого клиент теряет JSON, но видит network failure. Filter мог корректно сформировать 409, однако транспорт не доставил его. UI не имеет права утверждать конфликт version на основании любого отсутствия ответа. В Angular status0 получает отдельное сообщение соединения. Это показывает, что корректный серверный envelope помогает только когда клиент его получил; неоднозначный outcome после мутации остаётся частью протокола, описанного в CONTRACT.md и README.
Для будущей проверки отправьте неверный token, неверный title, rq-999 и старую version. Сопоставьте status, code и наличие requestId, а затем вызовите отказ подключения в отдельной лабораторной среде и ожидайте обобщённый 500 без DATABASE_URL. Эти действия не выполнялись и не являются тестовым отчётом. Они задают конкретные наблюдения для проверки написанного фильтра, а также помогают увидеть, какие отказы возникли до controller и какие — внутри repository.
Далее OpenAPI опишет те же DTO, формы успешных ответов и failures. Схема не выполнит нашу runtime-проверку вместо pipe и не докажет покрытие каждого exception. Но согласованный документ даст второму клиенту понятный contract. Нынешний filter уже обеспечивает стабильную структуру ожидаемых ошибок, сохраняя причины на уровне статусов и позволяя отделить пользовательское сообщение от внутренних деталей хранения.
Статус примера — not_run. Зависимости, приложение, SQL, HTTP и тесты не запускались. Описанные ответы и изменения интерфейса ожидаемые; чтение документации не является проверкой выполнением. Полная конечная версия находится в архиве исходников, а её ограничения и действия читателя — в README и CONTRACT.md.
Перейдите к оглавлению серии или общему маршруту интерактивной веб-разработки. Механизм этой главы сверяется с официальным первичным источником; конкретные версии и непроверенные части перечислены в договоре курса.