Доменные коды вместо статусов: один формат ответа об ошибке
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Отказывать ваш каркас умеет: страж отвечает «доступ закрыт», труба-валидатор — списком испорченных полей, маршрутизатор — «нет такого адреса». Все три отказа про транспорт: не туда пришли, не с тем токеном, не то прислали. А у самого приложения отказы другие — «почта занята», «заметка не ваша», «срок ссылки вышел», — и сказать их клиенту нечем, кроме статуса и строки текста. Клиент в ответ начинает выбирать подпись пользователю по тексту, который на сервере правят редакторы. В этой статье у отказа появится доменный код: одно слово, по которому клиент выбирает подпись, а статус этому слову назначает одна таблица на краю приложения. Заодно выяснится, откуда сервер вообще узнаёт, что почта занята, — и почему ответ на этот вопрос лежит в поле cause чужой ошибки.
Статусов HTTP немного, и говорят они про транспорт. 409 значит «то, что вы прислали, конфликтует с нынешним состоянием» — и ровно столько. Какое именно состояние и что с ним делать, статус не сообщает. А на регистрации конфликтов сразу два: занята почта и занят телефон. Беды разные, чинятся по-разному («войдите» против «укажите другой номер»), а статус у них один.
Значит, различать их клиенту придётся по единственному, что едет вместе со статусом, — по тексту. Ниже так и сделано: сервис отвечает HttpError(409, …), а клиент сравнивает пришедшую строку с той, которую помнит. Через неделю формулировку на сервере поправили — одно слово.
Прогноз: четыре строки, все со статусом 409. Какая из четырёх подписей потеряется и почему именно она?
Третья строка — то, ради чего пример и написан. Запрос тот же, отказ тот же, статус тот же, а пользователь вместо внятного «войдите или восстановите пароль» получил «что-то пошло не так». Сервер при этом не сломался и в журнале ничего не написал: с его точки зрения всё в порядке, он ответил как договаривались. Договорённость была устной — по тексту.
Текст плох в роли интерфейса не потому, что его правят, а потому, что правят его другие люди. Формулировки отказов редактируют, переводят, смягчают по просьбе поддержки — и делают это, не открывая клиента. Ни один инструмент при этом не скажет, что вместе со словом сломалась подпись: для компилятора, тестов и мониторинга ничего не произошло.
Вторая беда в этом примере тише первой. Статус 409 выбрал SignupService — слой про учётные записи, а не про протокол. Пока сервер один и отвечает по HTTP, это никому не мешает. Мешать начнёт, когда тот же метод позовут из консольной команды, из обработчика очереди или из процедуры tRPC: там никакого «409» нет, а отказ «почта занята» — есть.
Текст ответа — не интерфейс. Интерфейсом его делает только договорённость, а договорённость, о которой не знает ни один инструмент, живёт до ближайшей редактуры.
Про то, чем отличать одну ошибку от другой, в учебнике уже был разговор. «Доменные ошибки вместо кодов» сравнивала два способа — строковый код и свой класс — и выбрала класс: опечатка в строке молча даёт false, опечатка в имени класса — сразу ошибка. Здесь понадобятся оба, и это не отказ от прежнего вывода.
Разница в том, где происходит различение. Внутри процесса работает класс: instanceof, поля, общий предок. Но по проводу едет текст, и на другой стороне из него получается обычный объект: прототип обычный, методов нет, instanceof на нём не работает и работать не может. Дорогу переживают только значения полей. Поэтому наружу едет строка — доменный код, — а внутри процесса её несёт класс.
Список кодов пишется один раз, значением, а тип выводится из него — as const и индекс [number]. Дальше fail: объявление функции с возвращаемым типом never — она всегда бросает, и код после её вызова компилятор считает недостижимым. Объявление тут не придирка: у стрелки, присвоенной в const, тот же never на выводы о достижимости не влияет — такой вывод компилятор делает только для объявленной функции или для имени, которому тип написали отдельно. Ни в сервисе, ни в самой ошибке слова «HTTP» больше нет.
На другой стороне подпись выбирает таблица Record<DomainErrorCode, string>. Это тот самый словарь вместо switch: полнота достаётся даром, потому что добавленный в список код ломает сборку у всех, кто такой словарь держит.
Прогноз: пять строк. Что достанется журналу сервера в ту же секунду, когда пользователь увидит подпись, — и на что пожалуется компилятор во второй таблице, если снять с неё директиву?
Подпись выбрал клиент, и выбрал её по коду. Теперь формулировку на сервере можно править сколько угодно: до пользователя она не доезжает вовсе — до него доезжает EMAIL_TAKEN. Перевести интерфейс на другой язык стало делом одного клиента, и сервер об этом даже не узнает.
Текст при этом не пропал, а нашёл себе правильного читателя: EMAIL_TAKEN: ann@example.com уходит в журнал — там подробность полезна, потому что читает её тот, кто чинит. Разделение простое: код — для клиента, текст — для того, кто разбирает инцидент.
Вторая таблица — про сборку. Снимите с неё @ts-expect-error, и компилятор скажет TS2741 — Свойство "NOT_YOUR_NOTE" отсутствует в типе … и является обязательным в типе "Record<…>". Ровно это и нужно: список кодов один, и всякий, кто по нему что-то раскладывает, узнаёт о новом коде от компилятора, а не от пользователя.
Утечка. Ответ «эта почта занята» сообщает любому желающему, что такой пользователь у вас есть, и перебором по списку адресов из чужой утечки можно собрать вашу базу пользователей. Это известная цена, и большинство сервисов её платят: форма регистрации, которая не говорит «адрес занят», теряет больше на растерянных людях, чем выигрывает на разведке.
Там, где цена неприемлема — почтовые сервисы, медицина, финансы, — регистрацию устраивают иначе: форма всегда отвечает одинаково («мы отправили письмо»), а правду человек узнаёт из самого письма — либо «подтвердите адрес», либо «на этот адрес уже есть учётная запись, вот вход». Ответ сервера при этом один на оба случая, и выбирать подпись не по чему.
Важно, что это решение о продукте, а не о формате ответа. Доменный код нужен и здесь: просто кодов становится меньше, а не больше.
Код выбирает подпись, текст объясняет журналу. Если подпись выбирают по тексту, интерфейсов у вас два, и второй никто не поддерживает.
Код не отменяет статуса. Статус читают те, кто в ваше приложение не заглядывает: браузер решает по нему, сохранять ли ответ в кеш, прокси — повторять ли запрос, мониторинг — считать ли это падением. Ответить «409» надо по-прежнему. Вопрос только в том, кто это «409» выбирает.
Кто угодно, кроме того, кто отказал. Место, где отказ становится ответом, в каркасе ровно одно — ловец перед отправкой: он уже переводит HttpError в ответ, а всё незнакомое — в пятисотку. Ему и достанется перевод доменного кода. Сам перевод — функция toResponse, и весь он состоит из двух таблиц и трёх веток.
Таблицы зеркальные, и это не совпадение. У доменного отказа есть код, но нет статуса — статус ему назначает STATUS_OF. У отказа самого каркаса наоборот: статус он знает с рождения, а кода у него нет — код ему выдаёт TRANSPORT_CODE. После обеих таблиц у любого отказа есть и то и другое, и тело ответа собирается одинаково.
Третья ветка — самая важная. Исключение, которого вы не предвидели, не должно рассказывать о себе клиенту ничего: ни текста, ни стека, ни тем более куска SQL-запроса. Гостю не рассказывают, что не так с его телом — здесь то же правило, только гость другой и карта подробнее. Наружу едет 500 и код INTERNAL, подробности — в журнал.
Прогноз: четыре отказа. Какие статусы им достанутся — и почему строк в выводе пять, а не четыре?
Формат ответа об ошибке стал один на всё приложение: объект с полем code. Клиенту всё равно, отказал ему страж, маршрутизатор или сервис, — разбирать он будет одно и то же поле. А TypeError напечатал две строки: одну в журнал, где видно и имя, и сообщение, и одну в ответ, где нет ни того ни другого.
Обратите внимание, чего в toResponse нет: в ней ни одного if про конкретный код. Появится завтра код RESET_LINK_EXPIRED — правится строка таблицы, а не эта функция. Правится, к тому же, обязательно: без строки в STATUS_OF сборка не пройдёт.
Правок три, и все короткие. Маршрутизатор перестал отвечать сам и стал отказывать исключением, как все остальные; ловец перестал решать сам и стал звать перевод; заготовка ответа, с которой начинается inject, тоже стала кодом — иначе звено, вернувшееся без next(), отвечало бы в старом формате. Всё остальное в createApp осталось дословно тем же, что собрала глава 5.
Перевод при этом по-прежнему прибит к каркасу гвоздями: поменять его на отдельном маршруте нечем. Узел, который это умеет, — фильтр, и он приедет в следующей статье.
Статус выбирает край приложения, а не тот, кто отказал. Если сервис знает про 409, он знает про HTTP — и в очереди или в консольной команде это знание окажется лишним.
Остался вопрос, который мы до сих пор обходили: откуда сервер узнаёт, что почта занята? Напрашивается «спросить» — SELECT … WHERE email = $1, и если строка нашлась, отказать. Ответ неверный, и неверен он не в теории. Между вашим вопросом и вашей вставкой проходит время, и за это время ту же почту успевает вставить соседний запрос: две регистрации шли одновременно, обе спросили, обе получили «свободно».
Надёжный ответ даёт сама база — отказом уникального индекса. У каждой ошибки Postgres есть пятисимвольный код SQLSTATE; 23505 — нарушение уникальности. Драйвер pg кладёт его в поле code, а имя нарушенного ограничения — в constraint. Ветвиться нужно по ним, а не по тексту: текст сообщения зависит от языка, на котором говорит сервер базы.
Только ошибка драйвера доезжает до вас не всегда. Библиотека доступа к данным ловит её и бросает свою — drizzle 0.45, например, бросает ошибку с текстом Failed query: …, а исходную кладёт в cause. Поэтому проверка смотрит два уровня: саму ошибку и то, что лежит у неё в cause.
Имя индекса в проверке — не перестраховка. У таблицы пользователей два уникальных индекса, по почте и по телефону, и оба дают один и тот же 23505. Без сверки имени любое нарушение уникальности превратилось бы в «почта занята» — в том числе нарушение по телефону.
Прогноз: пять ответов, а строк шесть. Два отказа доехали до сервиса по-разному — с кодом наверху и с кодом внутри cause: будет ли это видно в ответе? И что получит клиент, когда база откажет по причине, которой никто не ждал?
Две занятости дали два разных кода, хотя SQLSTATE у них один: различило их имя индекса. При этом почту сервису вернул драйвер напрямую, а телефон приехал завёрнутым — на ответ это не повлияло никак.
Четвёртый запрос — про границу. 23502 (обязательное поле пустое) доменным кодом не стал: сервис его не узнал и пробросил дальше, ловец перевёл в 500, а подробность ушла в журнал. Так и надо. Доменный код объявляют тому, что предвидели и решили показать клиенту; всё остальное — это ошибка, и делать вид, что она часть договора, значит прятать её от себя.
В песочнице настоящей базы нет: ошибки здесь собраны руками по образцу настоящих — поля code и constraint те же, текст взят у Postgres. Настоящую такую ошибку вы увидите в главе про данные: когда вместо своей таблицы появится Postgres, а своя таблица будет ловить ровно эту же isUniqueViolation.
Занятость проверяет база, а не ваш SELECT. Проверка перед вставкой отвечает на вопрос «было ли занято полсекунды назад», а вам нужен ответ на вопрос «занято ли сейчас».
У учебника, который вы читаете, есть свой API, и отказывает он ровно так же. Ниже файл apps/api/src/common/domain-error.ts — выдержка: в комментарии шапки сокращена одна фраза, остальное дословно.
Список кодов — тот же приём, вплоть до as const и [number]. Таблица тоже на месте, только переводит она не в статус, а в код tRPC, и статус из него выводит уже сам tRPC: CONFLICT у него значит 409, FORBIDDEN — 403. Кодов у tRPC двадцать один, и все они транспортные — «почты занятой» среди них нет и быть не может.
Отсюда и строчка message: code, которая на первый взгляд выглядит странно: доменный код кладётся в поле сообщения, потому что другого места для него в ответе tRPC нет. Клиенты читают message и по нему выбирают подпись — то есть делают ровно то, что делала наша таблица CAPTION.
Строка RESET_TOKEN_INVALID: 'BAD_REQUEST' с комментарием — пример того, ради чего таблицу и заводят. Просроченная ссылка восстановления похожа на 401, но 401 значит «повторите с правильным токеном», а повторять тут нечего: ссылку надо запрашивать заново. Такие решения принимаются один раз и видны все сразу — потому что лежат в одной таблице, а не по сервисам.
Одно замечание к комментарию у isUniqueViolation: заворачивает ошибку не драйвер, а библиотека доступа к данным — pg отдаёт свою ошибку как есть, а cause появляется уровнем выше. На работу функции это не влияет (два уровня она смотрит в любом случае), но при чтении чужого кода такие мелочи стоит проверять, а не принимать на веру.
И последнее, чего в файле нет. Ограничитель частоты запросов в том же API отвечает TOO_MANY_REQUESTS мимо domainError — и это верно: «слишком часто» не отказ предметной области, а отказ транспорта, как 404 у маршрутизатора. В список доменных кодов попадает только то, о чём приложение знает как о своём.
Список доменных кодов — маленький. Если он растёт быстро, посмотрите, не попали ли в него отказы транспорта: у них свой список, и он придуман не вами.
Узлы те же, названия чужие. Главное отличие видно в первой же строке таблицы, и оно не косметическое.
| Узел вашего каркаса | Как это в Nest 12 | Как это в tRPC 11 |
|---|---|---|
DomainError с полем code | HttpException и наследники — но статус они несут сами | TRPCError с кодом из двадцати одного |
таблица STATUS_OF | её нет: статус выбирает класс исключения | её нет: статус выводится из кода |
| доменный код в теле ответа | errorCode в опциях исключения — поле появилось в 12-й версии | своего места нет, код кладут в message |
toResponse внутри ловца | фильтр: @Catch, @UseFilters, useGlobalFilters | errorFormatter в настройках |
тело {"code": …} | { statusCode, message, error?, errorCode? } | { message, code, data: { code, httpStatus, path } } |
500 без подробностей | по умолчанию так же: наружу «Internal server error» | стек кладётся в data.stack только в разработке |
Первая строка — та самая ловушка, из-за которой статья вообще нужна. new NotFoundException() выглядит удобно и пишется коротко, но это 404, выбранный там, где его бросили. Сервис, который бросает NotFoundException, знает про HTTP — со всеми последствиями из первого раздела. Лечится это так же, как у нас: свой класс с доменным кодом внутри, а исключения Nest — только на краю, в фильтре.
Место для кода в Nest 12 при этом есть. У всех исключений в опциях появилось поле errorCode, и то, что в него положили, приезжает в теле ответа отдельным полем: { statusCode: 404, message: "…", errorCode: "NOTE_NOT_FOUND" }. В 11-й версии такого поля не было вовсе. Боевой API и сейчас кладёт код в message, но по другой причине: в ответе tRPC отдельного места для кода нет.
Наш ловец от фильтра отличается одним: он прибит к сборке каркаса, а фильтр — обычный усилитель. Его вешают на метод, на контроллер или на всё приложение, и уровней у него столько же, сколько у стража и трубы. Как это устроено — в статье про настоящий Nest; как то же самое сделать в своём каркасе — в следующей.
Доменные коды дальше будут появляться в каждой второй главе. Ближайшие — в главе про данные, где та же isUniqueViolation встретит настоящую базу, и в главе про доступ, где выяснится, что чужой черновик честнее объявить несуществующим, чем запретным: NOT_FOUND вместо NOT_YOUR_NOTE не рассказывает чужому, что такой черновик вообще есть. А чужой опубликованной заметке там отвечают именно запретом — скрывать существование того, что человек и так видит в ленте, не от кого.
Исключение фреймворка несёт статус. Доменный код статуса не несёт — и ровно поэтому его можно бросать из сервиса, который ничего не знает ни про HTTP, ни про tRPC.
- Статус HTTP говорит про транспорт и не различает две разные беды с одним кодом. Клиент, который выбирает подпись по тексту ответа, держится на договорённости, о которой не знает ни один инструмент, — и ломается от правки формулировки.
- Доменный код — строка, которую понимают обе стороны: внутри процесса её несёт свой класс и различают через
instanceof, а по проводу едет только значение, потому что класс границу процесса не переживает. - Список кодов пишется один раз значением, тип выводится из него, а всякая таблица
Recordпо этому типу — подписи у клиента, статусы на сервере — перестаёт собираться, пока новому коду не назначили строку. - Перевод кода в статус живёт на краю приложения, в одном месте: у доменного отказа нет статуса, у каркасного нет кода, а чужое исключение не выносится наружу вовсе —
500и код, подробности в журнал. - Занятость определяет база, а не проверка перед вставкой: между
SELECTиINSERTвстаёт соседний запрос. Признак —23505и имя нарушенного индекса, и лежат они либо на ошибке драйвера, либо в еёcause. - В Nest исключения несут статус сами, и брошенный в сервисе
NotFoundException— это HTTP, приехавший в предметную область; место для доменного кода там есть —errorCodeв Nest 12 иmessageу tRPC.
1/4Клиент выбирает подпись пользователю по тексту ответа сервера. Почему это ломается?
2/4Внутри программы лучше свой класс, а наружу едет строковый код. Почему не класс?
3/4Что даёт Record<DomainErrorCode, number> вместо обычного объекта со статусами?
4/4Почему isUniqueViolation смотрит и саму ошибку, и то, что лежит у неё в cause?
Код, которого клиент не знает
Сервер выкатили раньше клиента, и в ответах появился код NOTE_LOCKED, которого в клиентском списке нет. Сейчас такой ответ превращается в подпись undefined — пользователь видит это слово на экране. Напишите проверку, после которой незнакомый код (и ответ, где поля code нет вовсе) получает общую подпись, а знакомые — свои. Таблицу CAPTION не трогайте.
Из JSON приезжает unknown, и превратить его в DomainErrorCode может только проверка в рантайме — та самая, которую as подменял обещанием.
Приведение DOMAIN_CODES as readonly string[] здесь обязательно: у списка тип readonly ["EMAIL_TAKEN", …], и его includes принимает только эти литералы. Без приведения компилятор скажет TS2345 — Аргумент типа "string" нельзя назначить параметру типа ""EMAIL_TAKEN" | …". После правки обе последние строки отвечают общей подписью.
Общая подпись нужна не из вежливости. Клиент и сервер выкатываются порознь всегда: мобильное приложение обновляется неделями, а вкладка в браузере живёт открытой сутками. Значит, список кодов у клиента по определению отстаёт, и незнакомый код — не ошибка, а обычное состояние.
Проверка, которая не помогает
Сервис спрашивает у базы, занята ли почта, и только потом вставляет. Два запроса с одним адресом пришли одновременно — и второй упал так, что клиент получил бы 500. Перепишите register так, чтобы занятость определяла база: отказ 23505 по индексу users_email_unique должен превращаться в EMAIL_TAKEN. Метод exists после этого станет не нужен — его можно не трогать, а просто не звать.
Вопрос базе заменяется на попытку и разбор отказа.
Вторая строка вывода становится отказ EMAIL_TAKEN. Обратите внимание на await перед this.users.insert: без него промис вернулся бы из try раньше, чем отказал, и catch не выполнился бы вовсе — return без await внутри try ошибку не ловит.
И про сам зазор: в примере он сделан одним await, а в настоящей базе это два сетевых запроса подряд. Окно между ними больше, а не меньше, — и попадают в него не только злоумышленники, но и человек, дважды нажавший кнопку.
Первопричина, которая потерялась
Сервис поймал отказ базы и бросил доменный код — и в журнале от настоящей ошибки не осталось ничего: ни SQLSTATE, ни имени индекса, ни текста Postgres. Сделайте так, чтобы первопричина ехала вместе с доменным отказом, а в ответ клиенту по-прежнему уходил один код.
Своему конструктору нужно принять и пробросить второй аргумент Error — тот самый, у которого есть cause.
И на месте отказа: throw new DomainError("EMAIL_TAKEN", email, { cause: error }). Третья строка вывода после этого называет нарушенное ограничение. Тело ответа не меняется ни на символ — cause в JSON не попадает, потому что собирают его руками из одного поля.
Приём тот же, что и в 23505, только в обратную сторону: там вы читали чужой cause, здесь пишете свой. Цепочка из двух-трёх ошибок — нормальный журнал: верхняя говорит, что случилось с точки зрения приложения, нижняя — что ответила машина.