--- 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`.