заведён канон общих конвенций для личных проектов

- 13 конвенций по осям arch / lang / stack / common; репозитории берут
  оттуда копии в свой docs/conventions/ и коммитят их у себя
- conv — синхронизация копий: add / status / diff / pull / push, локальные
  регионы исключены из сравнения, поэтому расхождение не даёт шума
This commit is contained in:
av
2026-07-25 18:18:18 +03:00
commit 4a59c71737
15 changed files with 2142 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
---
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 -->