Files
dev-conventions/lang/go/errors.md
T
av 4a59c71737 заведён канон общих конвенций для личных проектов
- 13 конвенций по осям arch / lang / stack / common; репозитории берут
  оттуда копии в свой docs/conventions/ и коммитят их у себя
- conv — синхронизация копий: add / status / diff / pull / push, локальные
  регионы исключены из сравнения, поэтому расхождение не даёт шума
2026-07-25 18:18:18 +03:00

9.9 KiB
Raw Blame History

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 работает только в той горутине, где случилась паника.
  • Логирующая recover-граница пишет debug.Stack(). Это единственное место, где нужен стек-трейс: у восстановленной паники нет цепочки %w, и без стека «index out of range» не диагностируется вообще.

Несколько ошибок

Сбор независимых ошибок (валидация конфига — все проблемы разом) — errors.Join; проверка собранного по-прежнему через errors.Is.