- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
9.9 KiB
status
| status |
|---|
| рекомендуемая |
Ошибки
Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
логировать — в lang/go/logging.md, раздел «Ошибки» (коротко: лог один
раз на доменной границе).
Базовая идиома: stdlib
- Только стандартный
errors+fmt.Errorf. Контекст ошибки несётslog, а не стек: при дисциплине «каждый слой добавляет свой контекст» цепочка сообщений локализует место не хуже стека, а стек-трейсы и Sentry избыточны для домашнего сервиса. - Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал пересмотреть решение, а не дефолт, который можно обойти локально.
- Единственное исключение — восстановленная паника: у неё цепочки
%wнет вовсе (см. «panic»).
Обёртка и контекст
Сервис — приложение, а не библиотека: внешнего Go-API нет, весь код
наш. Возражение против дефолтного %w («обёрнутая ошибка становится частью
API») относится к библиотекам, поэтому внутри приложения обёртка %w —
дефолт, чтобы errors.Is и errors.As работали сквозь слои.
- Добавляем контекст обёрткой:
fmt.Errorf("parse magnet: %w", err). %w— когда вызывающий может инспектировать причину (обычный случай).%v— когда причину сознательно не раскрываем, чтобы не завязывать вызывающего на чужой тип ошибки.- От утечки внутренних ошибок наружу защищаемся не через
%vв цепочке, а трансляцией на внешней границе (ниже).
Стиль сообщения:
- со строчной буквы, без точки в конце, без «failed to» и «error» — обёртка и так читается как «контекст: причина»;
- контекст — операция или субъект:
"link target: %w", не"something failed"; - без заикания: каждый слой добавляет свой смысл, не повторяя нижний
(
"add to qbt: %w", а не"add download failed: add to qbt failed: …").
Две трансляции
Ошибка меняет форму дважды, и это разные преобразования.
Первая — у источника, инфраструктурная → доменная. Граничные ошибки
зависимостей транслируем там, где они возникли: sql.ErrNoRows → доменный
store.ErrNotFound в слое store, чтобы выше по коду не торчал
database/sql. То же для HTTP-клиентов, файловой системы, внешних SDK.
Вторая — на внешней границе, доменная → пользовательская. Описана ниже, в разделе про каналы.
Sentinel vs типизированные
- Sentinel (
var ErrNotFound = errors.New("not found")) — для условий, на которые ветвится код: нет записи, дубликат, неподдерживаемый источник. Проверяемerrors.Is. - Типизированная ошибка (тип с полями и методом
Error()) — когда вызывающему нужны данные ошибки: поле валидации, код, лимит. Достаёмerrors.As. Не плодим типы там, где хватает sentinel. - Матчинг по тексту сообщения запрещён — это то же самое, что публичный API из строки лога.
Граница: приватный канал vs публичный
Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от того, кто канал видит.
Приватный канал — логи (владелец сервиса). Полная ошибка со всей
цепочкой %w и контекстом. Пишется один раз на доменной границе.
Публичный канал — пользовательские поверхности (HTTP API, web-UI, бот). Сюда отдаём:
- человекочитаемое сообщение по доменной ошибке — не сырой
err.Error()и не детали реализации (database/sql, пути, стек); - корреляционный ключ для владельца — id сущности либо
request_id, чтобы по нему найти полную ошибку в логах. «При обработке загрузки произошла ошибка, download_id=…» вместо «произошла ошибка»; - маппинг доменной ошибки → сообщение и, для HTTP, статус — в одной точке на все транспорты. У транспортов без статусов (бот) от маппинга берётся только сообщение.
Новую штатную ветвь отказа (конфликт, валидация) заводим sentinel'ом и
сразу добавляем в маппинг — иначе default отдаст 500 «внутренняя
ошибка» на нормальный конфликт, а логирующая граница спишет его в ERROR
вместо DEBUG.
Транзиентный ответ vs персистентная диагностика
У публичной границы две разные поверхности, и правило сырого текста для них разное:
- Транзиентный ответ на действие (тело ответа,
?err=, реплика бота по результату команды) — строго нейтральный: маппинг выше,err.Error()наружу не идёт, полная ошибка живёт в логах по корреляционному ключу. - Персистентная диагностика состояния — причина ухода записи в ошибочное состояние, сохранённая в БД и показываемая оператору. Здесь сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) допустим и полезен — но только пока поверхность видит исключительно владелец. Появился второй зритель или публичный доступ к экрану состояния — поверхность стала публичным каналом, и правило нейтрального текста распространяется на неё. Секреты запрещены абсолютно в обоих случаях; источник вычищается на границе клиента.
Различие работает, только если поверхности не смешиваются в одном поле. Диагностику кладём в отдельное поле, а не в доменное.
panic
panic— только для невосстановимого: нарушенный инвариант (баг программиста), ошибка инициализации, из которой нельзя стартовать.- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
ввод) — это значения
error. recover— на верхней границе каждой обрабатывающей единицы, а не только у HTTP:- HTTP middleware —
net/httpсам восстанавливает панику в хендлере и процесс не роняет, поэтому смысл своегоrecoverв другом: отдать контролируемый 500 и записать событие вslog, а не в stdlib-логгер; - цикл обработки апдейтов бота и фоновый воркер — вот здесь паника в
горутине действительно роняет процесс, и
recoverобязателен.recoverработает только в той горутине, где случилась паника.
- HTTP middleware —
- Логирующая recover-граница пишет
debug.Stack(). Это единственное место, где нужен стек-трейс: у восстановленной паники нет цепочки%w, и без стека «index out of range» не диагностируется вообще.
Несколько ошибок
Сбор независимых ошибок (валидация конфига — все проблемы разом) —
errors.Join; проверка собранного по-прежнему через errors.Is.