# Ошибки Конвенция: *как* устроены и передаются ошибки в 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», а не «произошла ошибка» и не сырой текст; - **маппинг доменной ошибки → статус/сообщение** (в jellybit — `httpapi.classifyErr`, единая точка для REST и веб-UI): | Доменная ошибка | Статус | Сообщение | |---|---|---| | `store.ErrNotFound` | 404 | «не найдено» | | `magnet.ErrNotMagnet` / `torrent.ErrNotTorrent` | 400 | «некорректный источник» | | `worker.ErrInvalidInput` (промах ввода команды) | 400 | «некорректный ввод» | | `worker.ErrNotReady` (источник ещё качается) | 409 | «торрент ещё качается…» | | `layout.ErrCollision` (цель занята, ушло в 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-карточке. Это **операторская поверхность владельца**: сервис однопользовательский в доверенной LAN (см. [architecture.md](../architecture.md)), эти поля — диагностический контекст для того, кто разбирает задачу. Здесь сырой текст ошибки (пути, фрагмент ответа LLM/qBittorrent) **допустим и полезен** — но: - **секреты запрещены** абсолютно (токены/ключи/пароли/`Authorization`) — так же, как в логах ([logging.md](logging.md), «Безопасность»). Источник error_msg вычищаем на границе клиента (`logging.SanitizeErr` для ошибок транспорта, несущих URL с секретом); - это **не** канал для транзиентных отказов команд — те остаются нейтральными (см. выше). ## panic - `panic` — только для невосстановимого: баг программиста (нарушенный инвариант), ошибка инициализации, из которой нельзя стартовать. - Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) — это значения `error`. - `recover` — на верхней границе обработчика (HTTP middleware), чтобы один паникующий запрос не ронял процесс. ## Несколько ошибок - Сбор независимых ошибок (напр. валидация конфига — все проблемы разом) — `errors.Join`; проверка собранного по-прежнему через `errors.Is`.