# Ошибки

Конвенция: *как* устроены и передаются ошибки в jellybit. Правила оформления
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как
ошибки строятся, оборачиваются и проверяются.

**Механизировано:** сторонние пакеты ошибок — `depguard`; `err == ErrX` и
приведение типа — `errorlint`; матчинг по тексту сообщения — `internal/archrules`.

## Базовая идиома: stdlib

- Только стандартный `errors` + `fmt.Errorf`: контекст ошибки несёт `slog`, а не
  стек — стек-трейсы и Sentry избыточны для домашнего сервиса.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
  пересмотреть, а не дефолт.

## Обёртка и контекст

jellybit — **приложение, а не библиотека**: внешнего Go-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`.

## Sentinel vs типизированные

- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий,
  на которые ветвится код (нет записи, дубликат по infohash,
  неподдерживаемый источник). Проверяем `errors.Is`.
- **Типизированная ошибка** (тип с полями + метод `Error()`) — когда
  вызывающему нужны **данные** ошибки (поле валидации, код). Достаём
  `errors.As`. Не плодим типы там, где хватает sentinel.

## Граница и трансляция: приватный vs публичный канал

Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**,
и форма зависит от канала, кто его видит:

- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей
  цепочкой `%w` и контекстом. Пишется один раз на доменной границе — см.
  [logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (Telegram, web-UI, HTTP
  API; ими пользуется не только владелец). Сюда отдаём:
  - **человекочитаемое сообщение** по доменной ошибке — не сырой
    `err.Error()` и не детали реализации (`database/sql`, пути, стек);
  - **+ корреляционный ключ** для владельца — `download_id` (если операция
    к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
    Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
    не «произошла ошибка» и не сырой текст.
    **Ключ есть не у всякого транспорта, и это называется вслух.** `request_id`
    — понятие HTTP-границы (chi `RequestID`); у Telegram и CLI его нет. Если
    операция ещё не завела загрузку (отказ приёма), у такого транспорта ключа
    нет вовсе — тогда сообщение остаётся без якоря, а диагностика ищется по
    записи доменной границы (`capability`, `infohash`). Заводить транспорту
    собственный идентификатор запроса ради ключа — решение уровня спеки, а не
    умолчание: второй канал корреляции рядом с существующим дороже, чем
    отсутствие ключа;
  - **маппинг доменной ошибки → статус/сообщение** (в jellybit —
    `httpapi.classifyErr`, единая точка для REST и веб-UI):

    | Доменная ошибка | Статус | Сообщение |
    |---|---|---|
    | `store.ErrNotFound` | 404 | «не найдено» |
    | `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» |
    | `ingest.ErrTorrentTooLarge` (файл больше лимита) | 400 | «файл .torrent слишком большой» |
    | `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» |
    | `errManualSource` (ручной ввод источника, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
    | `errInvalidCandidate` (выбран несуществующий кандидат, локальный sentinel `httpapi`) | 400 | текст самой ошибки |
    | `errBatchEmpty` / `errBatchTooLarge` / `errBatchBadID` (разбор пачки группового удаления, локальные sentinel'ы `httpapi`) | 400 | текст самой ошибки |
    | `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» |
    | `layout.ErrCollision` (цель занята, ушло в review) | 409 | «целевой файл уже существует…» |
    | `layout.ErrNameTooLong` (целевое имя не помещается, ушло в review) | 409 | «целевое имя слишком длинное…» |
    | `worker.ErrConflict` (операция недопустима сейчас) | 409 | «действие недоступно в текущем состоянии» |
    | прочее | 500 | «внутренняя ошибка» |

    Новую штатную ветвь отказа (конфликт/валидация) заводим sentinel’ом и
    добавляем сюда — иначе `default` отдаст 500 «внутренняя ошибка» на
    нормальный конфликт (и логирующая граница спишет его в `ERROR` вместо
    `DEBUG`, см. [logging.md](logging.md)).

### Транзиентный ответ vs персистентная диагностика

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

- **Транзиентный ответ на действие** (тело REST/`?err=`/answer бота по
  результату команды) — строго нейтральный: маппинг выше, `err.Error()` наружу
  не идёт, полная ошибка — в логах по `download_id`/`request_id`.
- **Персистентная диагностика состояния** — `error_msg` перехода (причина ухода
  в review/failed: коллизия, рассинхрон, сбой ФС) и `reasons` распознавания,
  сохранённые в БД и показываемые на экране ревью и в Telegram-карточке. Это
  **операторская поверхность владельца**: сервис однопользовательский в
  доверенном контуре (см. [security.md](../security.md) → «Периметр»), эти поля —
  диагностический контекст для того, кто разбирает задачу. Здесь сырой текст
  ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но:
  - **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так
    же, как в логах ([logging.md](logging.md), «Безопасность»). Источник
    error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок
    транспорта, несущих URL с секретом);
  - это **не** канал для транзиентных отказов команд — те остаются нейтральными
    (см. выше);
  - **внешнее значение в тексте усекается на границе, а его размер называется
    числом.** `error_msg` уезжает в баннер ревью, в панель действий и в карточку
    Telegram; имя файла на 400 байт занимает там экран целиком и оседает в БД
    навсегда. Усечение — серединой и по рунам (`layout.shorten`,
    `naming.truncate`, `tgbot.shorten`), точная величина остаётся числом рядом:
    без неё человек не поймёт, насколько сокращать.

## panic

- `panic` — только для невосстановимого: баг программиста (нарушенный
  инвариант), ошибка инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
  ввод) — это значения `error`.
- `recover` — на верхней границе обработчика (HTTP middleware), чтобы один
  паникующий запрос не ронял процесс.

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

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