- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
141 lines
9.9 KiB
Markdown
141 lines
9.9 KiB
Markdown
---
|
||
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 -->
|