# Классификация доменных ошибок (500→409/400) и конвенции логирования **Приоритет:** средний · **Теги:** review-fable, errors, logging Продолжение коммита `f8fb4fa` (логирование ошибок: доменная граница + защита секретов). Тот коммит закрыл «ядро + секреты» (Tier A) по итогам ревью двумя сабагентами Fable (инфраструктурные и доменные ошибки). Здесь — отложенные Tier B (классификация) и Tier C (конвенции + политика), не вошедшие в scope. ## Tier B — классификация ошибок (сейчас штатные конфликты/валидация → 500) Проблема: часть доменных отказов возвращается голым `fmt.Errorf` без sentinel, поэтому `classifyErr` (httpapi) отправляет их в `default` → **500 «внутренняя ошибка»** вместо 409/400. Побочно: новый `logCmd` (worker) логирует такие отказы как **ERROR**, хотя это норма (адресат — пользователь), должно быть DEBUG. - **Обернуть `ErrConflict`** в командах, где конфликт состояния не обёрнут (в отличие от `requireReviewable`/`Apply`/`Undo`/`Delete`, где уже сделано): - `worker.go:789` — `cancel: ... is already terminal`; - `worker.go:809` — `retry: ... only failed/stuck are retriable`; - `review.go:515` — `defer: ... is terminal`; - `review.go:551` — `undo: nothing to revert` (скорее конфликт). - **Sentinel для валидации ввода** (`ErrInvalidInput` → 400) или дооборачивание, чтобы промах пользователя не выглядел сбоем ни в статусе, ни в уровне лога: - `review.go` — `refine: empty hint`, `set type: invalid type`, `ignore: empty path`, `add source: invalid provider`/`empty id`, `set provider: invalid provider`/`empty id`, `choose candidate: candidate ... does not belong`. - **`classifyErr` не знает `layout.ErrCollision`**: ручной Apply с коллизией цели штатно уводит задачу в review с причиной (`review.go` linkPlan), но пользователь в вебе получает 500, а tgbot — «Не удалось выполнить действие». Добавить кейс (409 или сценарий «ушло в ревью: коллизия цели») и исключение в tgbot по аналогии с `ErrNotReady`. После sentinel-обёрток обновить `logCmd` (worker.go) — он уже относит `ErrConflict/ErrNotReady/ErrNotFound` в DEBUG; добавить туда новый `ErrInvalidInput`. ## Tier C — конвенции и политика (docs/conventions) Оба ревьюера предложили закрепить в `logging.md`/`errors.md` (чтобы дыры и дубли не возникали снова — уже дважды выстрелило: ingest, затем команды): - **Команды воркера = доменная граница.** Явно дописать в `logging.md`, раздел «Ошибки»: публичные методы воркера (`Apply`/`Refine`/`Cancel`/…) — граница домена, логируют исход ровно один раз (реализовано в `logCmd`); транспорты не логируют возвращённую ошибку. Сейчас формулировка «стадии воркера» двусмысленна. - **Таблица уровней доменных отказов:** `ErrConflict`/`ErrNotReady`/валидация → DEBUG (адресат — пользователь); нарушенный инвариант хранилища → INFO/WARN; прочее (БД/ФС/зависимости) → ERROR. Правило: у каждой доменной ошибки ровно один логирующий, уровень — по адресату, а не по месту. - **Правило про `*url.Error`/секреты в URL** (раздел «Безопасность»): ошибки HTTP-транспорта встраивают URL, который может нести секрет — санитизировать на границе клиента до лога и обёртки. Частично реализовано (`logging.SanitizeErr`, применён в `ExtCall`/tgbot/metadata) — осталось задокументировать + правило «секрет не кладём в URL, если у API есть заголовок». - **Уровень повторяющихся сбоев фоновых циклов:** `poll/sweep/list failed` = WARN, а та же ошибка БД в `ingest.Ingest` = ERROR. Договориться о едином правиле (транзиентный сбой тика → WARN; эскалация в ERROR при устойчивом сбое N тиков) — сейчас уровень зависит от места. Смежно: ERROR-шторм при недоступном qBittorrent (`qbt` логирует Failure на каждом тике поллинга). - **Единая категория переходов состояния:** свести к одному msg `state transition` (from/to/code); `download cancelled`/`download retried`/`relink re-recognizing` не теряются из-под `jq 'select(.msg=="state transition")'`. ## Развилка — сырой `err.Error()` на публичных поверхностях `reasons` (`review.go` recognizeOne) и `error_msg` переходов (`linkPlan`) показываются в review-экране и Telegram-карточке и могут нести детали реализации (тело LLM/qBit, абсолютные пути). Решить: (а) узаконить в `errors.md` как «операторская поверхность владельца» с явным запретом секретов; либо (б) держать там reason-код + нейтральный текст, полную ошибку — в лог по `download_id`. ## Мелочь (по желанию, из тех же ревью) - `httpapi/review.go` — поле `"id"` вместо словарного `"download_id"`; msg «review data» → категория «review data failed». - `worker/review.go` `parseIgnored` — `_ = json.Unmarshal` без обязательного комментария «почему» (битый override молча обнуляет игнор). - Reason-коды переходов (`"resolve"`/`"build"`/`"persist"`/`"collision"`/…) — свести в const-блок рядом с `errCode*` (сейчас только `reasonTitleFolderDesync`). - Превью в `ReviewData` при сбое — DEBUG, хотя это видимая деградация (пропадает кнопка «Применить»); уместнее WARN. Вердикт: change (Tier B — правки кода + миграция статусов ошибок; Tier C — правки конвенций + одно продуктовое решение по `err.Error()`).