- пятая, необязательная часть правила: код парой «плохо → хорошо» после обоснования; метка добавлена в словарь (EXAMPLES) и в строку о версии языка во всех тринадцати файлах - сказано, чем примеры не являются: требований в блоке нет, дословным сниппетом он не служит, при расхождении с нормой правят пример - READING.md обновлён по META-30, в машинные проверки добавлен порядок блоков, в читательские — что примеры норму не расширяют
31 KiB
topic, prefix
| topic | prefix |
|---|---|
| errors | GERR |
Ошибки
Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
логировать — в конвенции logging (коротко: лог один раз на доменной
границе).
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Две границы, о которых говорят правила ниже:
- доменная граница — место, где определяется исход операции: use-case, публичная команда воркера, стадия асинхронной обработки. Ниже неё ошибка только накапливает контекст, выше — операция уже либо удалась, либо нет.
- внешняя граница — место, где ответ покидает процесс: обработчик HTTP, рендер страницы, отправка сообщения ботом.
Одна операция проходит обе: сначала доменную (там её исход логируется), потом внешнюю (там он превращается в ответ).
Правила
GERR-1. Ошибки строятся средствами стандартной библиотеки
ДОЛЖЕН. Ошибки создаются и оборачиваются через errors и
fmt.Errorf; библиотеки со стек-трейсами не подключаются.
ПОЧЕМУ. Стек и цепочка обёрток решают одну задачу — локализацию места.
При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
slog. Библиотека со стеками добавляет зависимость, собственный тип ошибки
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
это цена без покупателя.
Единственное место, где стек всё-таки нужен, — восстановленная паника: у
неё цепочки %w нет вовсе (GERR-23).
GERR-2. Дефолт не обходится точечно
НЕ ДОЛЖЕН. Пакет со стек-трейсами не заводится в отдельном месте кодовой базы ради конкретной отладки.
ПОЧЕМУ. В коде появляются два способа устроить ошибку, и вызывающий
перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
errors.Is работает не везде одинаково. Хуже второе: боль, снятая
локально, перестаёт накапливаться — а накопление и есть единственный
сигнал, что решение GERR-1 пора пересматривать целиком.
GERR-3. Каждый слой добавляет свой контекст
ДОЛЖЕН. Ошибка, возвращаемая на уровень выше, оборачивается с
контекстом: fmt.Errorf("parse magnet: %w", err).
ПОЧЕМУ. На этом держится GERR-1: цепочка заменяет стек ровно настолько, насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста, стирает участок пути — по итоговому сообщению нельзя сказать, через какую операцию ошибка прошла, и отладка «no such file» начинается с чтения всего кода.
GERR-4. Обёртка по умолчанию — %w
СЛЕДУЕТ. Глагол выбирается по тому, раскрываем ли мы причину вызывающему:
| № | Ситуация | Глагол |
|---|---|---|
| GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | %w |
| GERR-4.2 | причину сознательно не раскрываем | %v |
ПОЧЕМУ. Возражение против дефолтного %w — «обёрнутая ошибка
становится частью API» — относится к библиотекам с внешними потребителями.
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
меняется вместе с вызывающими. Зато %v в середине цепочки обрывает
errors.Is и errors.As для всех слоёв выше, и ветвление по sentinel'у
(GERR-10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
ErrNotFound», далеко от места обрыва. GERR-4.2 остаётся для случая, когда
завязывать вызывающего на чужой тип ошибки не хотят намеренно.
GERR-5. Утечка внутренних деталей лечится трансляцией, а не %v
НЕ ДОЛЖЕН. %v не используется как средство не пустить внутреннюю
ошибку наружу.
ПОЧЕМУ. Обрыв цепочки внутри кода не мешает тексту уехать наружу
целиком: наружу отдаёт внешняя граница, и если она отдаёт err.Error(),
детали утекут при любом глаголе. Подмена не решает задачу, ради которой
сделана, а плату берёт сразу — errors.Is ломается у всех вызывающих.
Настоящее место защиты — GERR-13.
GERR-6. Текст обёртки — со строчной буквы и без служебных слов
СЛЕДУЕТ. Без точки в конце, без «failed to» и «error».
ПОЧЕМУ. Цепочка склеивается в одну строку через ": ", и обёртка
читается как «контекст: причина» — заглавные буквы и точки рвут эту строку
на середине. Слова «failed» и «error» не несут информации: то, что перед
нами ошибка, известно из того, что это ошибка. Зато повторяются они на
каждом уровне и вытесняют из строки полезный контекст.
GERR-7. Контекст обёртки называет операцию или субъект
СЛЕДУЕТ. В обёртку идёт то, что делал слой: "link target: %w".
ПОЧЕМУ. Обёртка ценна ровно тем, что сужает место (GERR-3). «something failed» не сужает ничего и при этом занимает в сообщении место, которое мог бы занять единственный полезный здесь факт — имя операции.
GERR-8. Слой не повторяет смысл нижнего
НЕ СЛЕДУЕТ. Обёртка не пересказывает то, что уже сказал уровень ниже:
"add to qbt: %w", а не "add download failed: add to qbt failed: …".
ПОЧЕМУ. Повтор удлиняет сообщение, не добавляя локализации: одно и то же событие названо дважды. Читателю приходится проверять, не два ли это разных места в коде, — то есть заикание не просто бесполезно, оно стоит времени при каждом чтении лога.
Две трансляции
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная → доменная у источника (GERR-9) и доменная → пользовательская на внешней границе (GERR-13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
GERR-9. Инфраструктурная ошибка транслируется в доменную у источника
ДОЛЖЕН. Граничная ошибка зависимости превращается в доменную там, где
возникла: sql.ErrNoRows → store.ErrNotFound в слое store; то же для
HTTP-клиентов, файловой системы, внешних SDK.
ПОЧЕМУ. Иначе тип зависимости становится частью контракта всех слоёв
выше: чтобы отличить «нет записи», доменный код импортирует database/sql
и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не
адаптер, а все ветвления в приложении — притом что снаружи адаптера
состояние «нет записи» одно и то же. Трансляция у источника оставляет
знание о зависимости в единственном слое, который её и так знает.
GERR-10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
ДОЛЖЕН. Между sentinel'ом и типом выбирают так:
| № | Что нужно вызывающему | Форма |
|---|---|---|
| GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel var ErrNotFound = errors.New("not found"), проверка errors.Is |
| GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом Error(), извлечение errors.As |
ПОЧЕМУ. Sentinel — одно значение; сравнение с ним не зависит от
структуры ошибки и переживает добавление полей. Тип заводится ради данных,
и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой
объявления, errors.As и вопроса «сравнивать по типу или по значению» на
каждой проверке. Две формы для одного условия — это два способа его
проверить, и про второй рано или поздно забудут.
GERR-11. Матчинг по тексту сообщения
НЕ ДОЛЖЕН. Ветвление по содержимому err.Error() не используется.
ПОЧЕМУ. Текст сообщения — не контракт: GERR-6–GERR-8 разрешают переписывать его свободно. Правка формулировки в нижнем слое молча ломает ветвление наверху, и компилятор этого не видит. Это то же самое, что публичный API из строки лога.
Граница: приватный канал и публичный
Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от того, кто канал видит: приватный канал — логи (их читает владелец сервиса), публичный — пользовательские поверхности (HTTP API, web-UI, бот).
GERR-12. Полная ошибка идёт в приватный канал
ДОЛЖЕН. В лог уходит вся цепочка %w с контекстом; где и когда именно
— конвенция logging.
ПОЧЕМУ. Цепочка — единственный носитель диагностики (GERR-1), и единственный канал, где её можно показать целиком, — тот, который видит владелец. Не записанная там, она не сохранится нигде: наружу идёт нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего.
GERR-13. Публичная поверхность получает сообщение по доменной ошибке
ДОЛЖЕН. Наружу идёт человекочитаемый текст по доменной ошибке, а не
err.Error() и не детали реализации (database/sql, пути, стек).
ПОЧЕМУ. Внутренние детали пользователю нечитаемы, а владельцу не нужны — у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен, причём раскрывают именно в момент, когда что-то пошло не так.
GERR-14. Публичное сообщение несёт корреляционный ключ
ДОЛЖЕН. Наружу вместе с сообщением идёт id сущности либо request_id:
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
ошибка».
ПОЧЕМУ. GERR-13 забирает у пользователя всю фактуру; без ключа его обращение звучит как «у меня что-то не работает», и владелец ищет запись в логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже видел.
GERR-15. Маппинг доменных ошибок — в одной точке на все транспорты
ДОЛЖЕН. Соответствие «доменная ошибка → сообщение и, для HTTP, статус» задаётся один раз; транспорт без статусов (бот) берёт из него только сообщение.
ПОЧЕМУ. Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте, и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина важнее: единственная точка — это место, куда механически дописывается новая ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде» ничем не проверяет.
GERR-16. Новая штатная ветвь отказа сразу попадает в маппинг
ДОЛЖЕН. Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и добавляется в маппинг (GERR-15) тем же изменением.
ПОЧЕМУ. Ветка default врёт в обе стороны: транспорт отдаёт 500
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
его в ERROR вместо DEBUG. Второе хуже первого — штатные отказы начинают
шуметь в логе ровно там, где по нему ищут настоящие поломки.
GERR-25. Непокрытая маппингом ошибка — 500 и ERROR с признаком
ДОЛЖЕН. Доменная ошибка, для которой в маппинге (GERR-15) нет ветви, отдаёт
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт ERROR с
признаком того, что маппинг её не знает.
ПОЧЕМУ. Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда
ERROR — уровень выбирается по адресату (конвенция logging). Статус
тоже не выбирается: известное пользовательское состояние лежало бы в
маппинге, а про неизвестное сказать пользователю нечего, поэтому 4xx
отпадает.
Признак нужен потому, что без него забытая ветвь неотличима от упавшей
базы: обе дают ERROR с текстом ошибки, и наткнуться на пропуск можно
только случайно. Отдельное поле или своя категория сообщения делают пропуск
находимым одним фильтром — и тогда громкость 500 и ERROR работает как
механизм обнаружения, а не как шум.
GERR-17. Форма текста определяется поверхностью
ДОЛЖЕН. У публичной границы две разные поверхности, и правило сырого текста для них разное:
| № | Поверхность | Текст ошибки |
|---|---|---|
| GERR-17.1 | транзиентный ответ на действие: тело ответа, ?err=, реплика бота по результату команды |
строго нейтральный, из маппинга (GERR-15); err.Error() наружу не идёт |
| GERR-17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
Появился второй зритель или публичный доступ к экрану состояния — поверхность стала публичным каналом, и на неё распространяется GERR-17.1.
ПОЧЕМУ. Транзиентный ответ читает тот, кто нажал кнопку: сырой текст ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла ошибка» в таком поле не несёт ничего и делает поле бессмысленным. Условие про единственного зрителя — ровно то, что делает вторую поверхность приватным каналом; без него это обычная публичная поверхность.
GERR-18. Секретов нет ни на одной из поверхностей
НЕ ДОЛЖЕН. Токены, пароли и ключи не попадают ни в транзиентный ответ, ни в персистентную диагностику; источник вычищается на границе клиента.
ПОЧЕМУ. Запрет абсолютен, потому что персистентная диагностика живёт в БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию самого секрета. Вычистка на границе клиента — единственное место, где ещё известно, какие поля запроса секретны: дальше ошибка едет как текст, и отличить в нём токен от идентификатора уже нельзя.
GERR-19. Диагностика хранится в отдельном поле
ДОЛЖЕН. Персистентная диагностика не кладётся в доменное поле, которое показывают пользователю.
ПОЧЕМУ. Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей разные поля. Одно поле на оба назначения означает, что при первом же показе записи наружу сырой текст уедет туда же — не по решению, а потому что поле одно.
panic
GERR-20. panic — только для невосстановимого
ДОЛЖЕН. Паникой отмечается нарушенный инвариант (баг программиста) и ошибка инициализации, из которой нельзя стартовать.
ПОЧЕМУ. Паника не оставляет вызывающему выбора: обработать её на месте нельзя, можно только уронить единицу обработки. Это верный ответ, когда состояние процесса перестало описываться кодом: работа с нарушенным инвариантом опаснее падения, а сервис, стартовавший без обязательной зависимости, всё равно откажет позже и непонятнее.
GERR-21. Ожидаемые ошибки — значения error
НЕ ДОЛЖЕН. Паника не используется для управления потоком: нет сети,
плохой ввод, отсутствующая запись возвращаются как error.
ПОЧЕМУ. Сигнатура — единственное, что сообщает вызывающему о возможном отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией «мы сломались».
GERR-22. recover — на верхней границе каждой обрабатывающей единицы
ДОЛЖЕН. Своя граница ставится у каждой единицы, мотив у них разный:
| № | Единица | Зачем recover |
|---|---|---|
| GERR-22.1 | HTTP-хендлер | net/http восстанавливает панику сам и процесс не роняет; свой recover нужен, чтобы отдать контролируемый 500 и записать событие в slog, а не в stdlib-логгер |
| GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; recover ставится в той же горутине |
ПОЧЕМУ. recover работает только в той горутине, где случилась паника,
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с
неожиданным полем гасят весь сервис, включая части, к этой ошибке
отношения не имеющие. У HTTP цена бездействия ниже, но не нулевая: паника
без своего recover уходит мимо структурированного лога, а клиент получает
оборванное соединение вместо ответа.
GERR-23. Recover-граница пишет debug.Stack()
ДОЛЖЕН. Логирующий recover кладёт в запись стек.
ПОЧЕМУ. Это единственное место, где стек нужен (GERR-1): у восстановленной
паники цепочки %w нет вовсе. «index out of range» без стека не
диагностируется в принципе — сообщение не называет ни файла, ни операции,
по нему нельзя сказать даже, в каком пакете упало.
Несколько ошибок
GERR-26. После recover единица продолжает работу, исключив упавшее
ДОЛЖЕН. Что происходит после перехвата, зависит от того, где стоит граница:
| № | Где перехвачена паника | Что дальше |
|---|---|---|
| GERR-26.1 | обработчик HTTP-запроса, паника любая, кроме сигнала намеренного прерывания | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
| GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
| GERR-26.3 | обработчик HTTP-запроса, паника — сигнал намеренного прерывания (http.ErrAbortHandler) |
значение пробрасывается дальше, ответ не подменяется |
ПОЧЕМУ. Паника внутри обработки одного элемента почти всегда говорит о
баге в работе с данными этого элемента, а не о порче общего состояния, —
останавливать всё остальное не за что. Довод «let it crash» здесь работает
не буквально: в OTP падает изолированный процесс под супервизором, а не узел
целиком, и в Go ближайшая замена такой изоляции — граница итерации, а не
граница процесса. Обратное при этом верно и делает recover в цикле
обязательным (GERR-22): неперехваченная паника в любой горутине завершает весь
процесс.
Исключение упавшего элемента в GERR-26.2 — не осторожность, а условие прогресса: детерминированная паника даёт бесконечный цикл — тот же элемент, тот же стек, залитый лог и нулевой прогресс. Это классический poison message, и лекарство здесь то же, что принято в очередях: элемент выводится из оборота, а не берётся снова. У цикла, который и так подтверждает прогресс — сдвигает офсет, помечает строку состоянием, — механизм для этого уже есть, заводить отдельный не нужно.
Оговорка «если ответ ещё не начат» в GERR-26.1 не формальность: статус отправляется один раз, и после первой записи в тело поменять его нечем — клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать ответ целиком до записи там, где это возможно.
Отдельная строка GERR-26.3 нужна потому, что http.ErrAbortHandler — не
отказ, а сигнал «прервать обработку намеренно»: подмена его на 500 превратила
бы штатный разрыв в ложную ошибку в логе и в метриках. Так поступают и
стандартные обёртки вроде chi.
GERR-24. Независимые ошибки собираются errors.Join
СЛЕДУЕТ. Валидация конфига и подобные проверки отдают все проблемы
разом; проверка собранного — по-прежнему через errors.Is.
ПОЧЕМУ. Возврат первой ошибки превращает починку конфига в серию
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
тот же список, но убивает ветвление: errors.Is по такому результату не
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
(GERR-11).
Связано
- конвенция
logging— где и когда ошибка попадает в лог. KEYS-7— формат корреляционного ключа изGERR-14.