Files
jellybit/docs/conventions/errors.md
T
avandClaude Opus 4.8 7d8a455e47 Логирование: классификация доменных ошибок (500→409/400) + конвенции
Штатные конфликты и промахи ввода возвращались голым fmt.Errorf, поэтому
classifyErr отправлял их в 500 «внутренняя ошибка» вместо 409/400 (и logCmd
писал ERROR вместо DEBUG). Продолжение f8fb4fa (Tier A), по итогам ревью Fable.

Классификация ошибок:
- новый sentinel worker.ErrInvalidInput → 400 для валидации ввода команд
  (refine/set type/ignore/add source/set provider/choose candidate);
- обёртки %w ErrConflict в Cancel/Retry/Defer/Undo (штатный конфликт состояния);
- classifyErr: ErrInvalidInput→400, layout.ErrCollision→409 (коллизия цели
  штатно уводит в review); ветка ErrCollision в tgbot (сообщение + refreshCard);
- logCmd относит ErrInvalidInput и ErrCollision в DEBUG «command rejected».

Конвенции (docs/conventions):
- logging.md: публичные команды воркера = доменная граница (лог один раз,
  logCmd); таблица уровней доменных отказов (граница команды vs асинхронная
  стадия); правило про *url.Error/секреты в URL; канон категории
  state transition; уровень повторяющихся сбоев фоновых циклов;
- errors.md: таблица маппинга ошибка→статус; развилка «транзиентный ответ vs
  персистентная диагностика» решена как (а) — error_msg/reasons на review-экране
  и tg-карточке = операторская поверхность владельца (сырой текст ок, секреты
  запрещены; аудит подтвердил, что секреты туда не текут).

Унификация категории лога state transition: cancel/retry/relink/recovery
переведены с семантических msg на общий state transition (from/to) — весь
жизненный цикл собирается одним jq-фильтром.

Мелочи: reason-коды linkPlan в const-блок; httpapi лог-поля id→download_id и
msg «… failed»; комментарий «почему» у parseIgnored; preview build failure в
ReviewData DEBUG→WARN.

Беклог: задача сведена к остатку (ext.* ERROR-шторм при недоступном qBittorrent
+ эскалация устойчивого сбоя тика), понижена в приоритете.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:57:12 +03:00

9.3 KiB
Raw Blame History

Ошибки

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

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

  • Только стандартный errors + fmt.Errorf. Без pkg/errors (в режиме поддержки) и cockroachdb/errors (стек-трейсы/Sentry — избыточно для домашнего сервиса). Контекст ошибки несёт slog, а не стек.
  • Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал пересмотреть, а не дефолт.

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

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: …").

Проверка ошибок

  • Сравнение — только errors.Is(err, ErrX) (не err == ErrX) и errors.As(err, &target). Никогда не матчим по тексту (strings.Contains(err.Error(), …)).
  • Граничные ошибки зависимостей транслируем в доменные у источника: 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.
  • Публичный канал — пользовательские поверхности (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).

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

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

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

panic

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

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

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