- Раскладка docs/ приведена к канону 2: заведены passport/architecture/ database/security/review и research; docs/specs, drafts, backlog, review/ и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи, 6 целей, слаги на английский). - Нарративы specs удалены как дубли openspec-спек после поимённой сверки; остаток заведён задачами (редактор маппинга ревью, крайние случаи именования), отказ от сущности title промоутнут в ADR. - Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon вместо er-schema.
123 lines
9.2 KiB
Markdown
123 lines
9.2 KiB
Markdown
# Ошибки
|
||
|
||
Конвенция: *как* устроены и передаются ошибки в 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`.
|