# Ошибки Конвенция: *как* устроены и передаются ошибки в transcriber. Правила оформления кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md), раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как ошибки строятся, оборачиваются и проверяются. **Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту. Главное: единой точки отображения доменной ошибки в ответ нет, обработчики решают сами. **Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`, сторонние пакеты ошибок — `depguard`, узнавание ошибки по тексту сообщения — тест-сканер `internal/archrules`. Перечень и адреса — [go-linters.md](go-linters.md), «Механизировано». ## Базовая идиома: stdlib - Только стандартный `errors` плюс `fmt.Errorf`: контекст ошибки несёт `slog`, а не стек — стек-трейсы и внешний сборщик избыточны для домашнего сервиса. - Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал пересмотреть, а не умолчание. ## Обёртка и контекст transcriber — **приложение, а не библиотека**: внешнего Go-API нет, весь код наш. Поэтому внутри приложения обёртка `%w` — **умолчание**, чтобы `errors.Is` и `errors.As` работали сквозь слои. - Добавляем контекст обёрткой: `fmt.Errorf("convert audio: %w", err)`. - `%w` — когда вызывающий может смотреть причину (наш обычный случай). `%v` — когда причину сознательно **не** раскрываем. - От утечки внутренних ошибок наружу защищаемся **не** через `%v` в цепочке, а трансляцией на внешней границе (см. ниже). Стиль сообщения: - со строчной, без точки в конце, без «failed to» и «error» — обёртка и так читается как «контекст: причина»; - контекст — операция или субъект: `"acquire job: %w"`, а не `"something failed"`; - без заикания: каждый слой добавляет **свой** смысл, не повторяет нижний. *Расхождение:* в коде преобладает форма `"failed to <действие>: %w"`. ## Проверка ошибок - Граничные ошибки зависимостей **транслируем в доменные у источника**: `sql.ErrNoRows` превращается в доменную ошибку в слое репозитория, чтобы выше по коду не торчал `database/sql`. - Проверяем `errors.Is` и `errors.As`, а не сравнением и не приведением типа. - **Признак домена читается только из ответа того шага, который его породил.** `errors.As` распознаёт признак на любой глубине цепочки, а не только сверху, — поэтому слой, придающий отказу собственный смысл, чужой признак в свою цепочку не сохраняет. Иначе воркер примет отказ, к которому признак примешался, за этот признак: зачтёт настоящий сбой пустым прогоном, и задача продолжит переопрашиваться без единой записи в журнале. Норма записана требованием [pipeline](../../openspec/specs/pipeline/spec.md). ## Sentinel и типизированные - **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий, на которые ветвится код. Проверяем `errors.Is`. - **Типизированная ошибка** (тип с полями плюс метод `Error()`) — когда вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там, где хватает sentinel. Типизированные ошибки проекта несут данные все до одной: `contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError` (состояние), `contract.LostAcquisitionError` (идентификатор задачи). Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а. Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь как случай, а не как живой код. ## Граница и трансляция: приватный и публичный канал Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**, и форма зависит от канала и от того, кто его видит: - **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой `%w` и контекстом. Пишется один раз на доменной границе — см. [logging.md](logging.md). - **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда отдаём: - **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и не детали реализации (`database/sql`, пути на диске, имена внешних сервисов); - **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему найти полную ошибку в логах. «При обработке задачи произошла ошибка, job_id = …», а не «произошла ошибка» и не сырой текст. **Ключ есть не у всякого транспорта, и это называется вслух.** Отказ приёма случается до заведения задачи, и ключа у него нет вовсе — тогда сообщение остаётся без якоря, а диагностика ищется по записи доменной границы. Заводить транспорту собственный идентификатор запроса ради ключа — решение уровня спеки, а не умолчание; - **отображение доменной ошибки в статус и сообщение** — единой точкой для HTTP и веба: | Доменная ошибка | Статус | Сообщение | | --- | --- | --- | | задача не найдена | 404 | «задача не найдена» | | файл не приложен, формат не распознан | 400 | «некорректный ввод» | | задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» | | прочее | 500 | «внутренняя ошибка» | Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а логирующая граница спишет его в `ERROR` вместо `DEBUG`. *Расхождение:* такой точки нет. `internal/controller/http/transcribe.go` отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на любую ошибку заведения задачи. ### Разовый ответ и сохранённая диагностика У публичной границы две поверхности, и правило сырого текста для них разное. - **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт, полная ошибка остаётся в логах по идентификатору задачи. - **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это **поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим и полезен. Но: - **секреты запрещены** — токены, ключи, пароли, заголовок авторизации. Ошибка транспорта может нести URL с токеном внутри, и её вычищают на границе клиента; - это **не** канал для разовых отказов — те остаются нейтральными; - **внешнее значение в тексте усекается на границе, а его размер называется числом рядом**: без этого непонятно, насколько сокращать. *Расхождение:* `error_text` пишется целиком, без вычистки и без усечения. Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без машинного текста — эту часть правила держит спека `intake`. ## panic - `panic` — только для невосстановимого: нарушенный инвариант, ошибка инициализации, из которой нельзя стартовать. - Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) — это значения `error`. - `recover` — на верхней границе обработчика, чтобы один паникующий запрос не ронял процесс. В transcriber его вешает роутер хранилища сам (`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId` на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в шаге конвейера роняет процесс целиком. ## Несколько ошибок - Сбор независимых ошибок (проверка конфига — все проблемы разом) — `errors.Join`; проверка собранного по-прежнему через `errors.Is`.