Штатные конфликты и промахи ввода возвращались голым 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>
9.3 KiB
Ошибки
Конвенция: как устроены и передаются ошибки в 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.ErrNotFound404 «не найдено» magnet.ErrNotMagnet/torrent.ErrNotTorrent400 «некорректный источник» 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.