заведён канон общих конвенций для личных проектов
- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
This commit is contained in:
@@ -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 -->
|
||||
Reference in New Issue
Block a user