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

141 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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`.
<!-- local:маппинг -->
<!-- /local -->
### Транзиентный ответ 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`.
<!-- local:механизировано -->
<!-- /local -->