# Ошибки

Конвенция: *как* устроены и передаются ошибки в 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 и веба:

    | Доменная ошибка | Статус | `error_code` | Сообщение |
    | --- | --- | --- | --- |
    | пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
    | запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
    | файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
    | запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
    | запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
    | текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
    | прочее | 500 | `internal` | «внутренняя ошибка» |

    Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
    случаем: им был владелец панели, предъявивший собственный токен хранилища.
    Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
    учётную запись заводит само.

    Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
    ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
    логирующая граница спишет его в `ERROR` вместо `DEBUG`.

    **Тело отказа несёт два поля — `error_code` и `message`.** Кода HTTP не
    хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три
    `400`, а приложению надо решать, предлагать ли повтор. Разбор русской фразы
    был бы единственным оставшимся путём. Норму держит спека `archive`.

    Точка живёт в `internal/controller/http.mapDomainError` и названа в
    [architecture.md](../architecture.md), «Единые точки проекта». Прежнее
    расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей
    `json-api-for-spa` 2026-08-15.

    **Часть отказов рождается не в обработчике** — предел тела, ограничитель
    частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
    файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
    перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
    отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
    общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
    тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
    неоткуда.

### Разовый ответ и сохранённая диагностика

У публичной границы две поверхности, и правило сырого текста для них разное.

- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
  полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
  **поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
  и полезен. Но:
  - **секреты запрещены** — токены, ключи, пароли, заголовок
    авторизации. Ошибка транспорта может нести URL с токеном внутри, и её
    вычищают на границе клиента;
  - это **не** канал для разовых отказов — те остаются нейтральными;
  - **внешнее значение в тексте усекается на границе, а его размер называется
    числом рядом**: без этого непонятно, насколько сокращать.

  *Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
  Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
  машинного текста — эту часть правила держит спека `archive`.

## panic

- `panic` — только для невосстановимого: нарушенный инвариант, ошибка
  инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
  это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
  ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
  обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
  владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
  чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
  роняет процесс целиком.

## Несколько ошибок

- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
  `errors.Join`; проверка собранного по-прежнему через `errors.Is`.

*Расхождение:* проверку конфига пункт называет поимённо, а ни одна из них так не
устроена: `errors.Join` в `internal/config` не зовётся нигде, и всякая проверка
возвращается на первом несовпадении. Заметило ревью задачи
`config-test-headers-login` 2026-08-23 — тем же прогоном, каким добавили
`ValidateTestHeaders`, ведущую себя так же. Человек, заполняющий конфиг
впервые, чинит одну ошибку за прогон.
