Files
transcriber/docs/conventions/errors.md
T
av 8f7c3a057a удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
2026-08-15 07:24:35 +03:00

153 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ошибки
Конвенция: *как* устроены и передаются ошибки в transcriber. Правила оформления
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как ошибки
строятся, оборачиваются и проверяются.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
решают сами.
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`,
сторонние пакеты ошибок — `depguard`, узнавание ошибки по тексту сообщения —
тест-сканер `internal/archrules`. Перечень и адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Базовая идиома: stdlib
- Только стандартный `errors` плюс `fmt.Errorf`: контекст ошибки несёт `slog`, а
не стек — стек-трейсы и внешний сборщик избыточны для домашнего сервиса.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
пересмотреть, а не умолчание.
## Обёртка и контекст
transcriber — **приложение, а не библиотека**: внешнего Go-API нет, весь код наш.
Поэтому внутри приложения обёртка `%w`**умолчание**, чтобы `errors.Is` и
`errors.As` работали сквозь слои.
- Добавляем контекст обёрткой: `fmt.Errorf("convert audio: %w", err)`.
- `%w` — когда вызывающий может смотреть причину (наш обычный случай). `%v`
когда причину сознательно **не** раскрываем.
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в цепочке, а
трансляцией на внешней границе (см. ниже).
Стиль сообщения:
- со строчной, без точки в конце, без «failed to» и «error» — обёртка и так
читается как «контекст: причина»;
- контекст — операция или субъект: `"acquire job: %w"`, а не
`"something failed"`;
- без заикания: каждый слой добавляет **свой** смысл, не повторяет нижний.
*Расхождение:* в коде преобладает форма `"failed to <действие>: %w"`.
## Проверка ошибок
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
`sql.ErrNoRows` превращается в доменную ошибку в слое репозитория, чтобы выше
по коду не торчал `database/sql`.
- Проверяем `errors.Is` и `errors.As`, а не сравнением и не приведением типа.
- **Признак домена читается только из ответа того шага, который его породил.**
`errors.As` распознаёт признак на любой глубине цепочки, а не только сверху,
— поэтому слой, придающий отказу собственный смысл, чужой признак в свою
цепочку не сохраняет. Иначе воркер примет отказ, к которому признак
примешался, за этот признак: зачтёт настоящий сбой пустым прогоном, и задача
продолжит переопрашиваться без единой записи в журнале. Норма записана требованием
[pipeline](../../openspec/specs/pipeline/spec.md).
## Sentinel и типизированные
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий, на
которые ветвится код. Проверяем `errors.Is`.
- **Типизированная ошибка** (тип с полями плюс метод `Error()`) — когда
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
где хватает sentinel.
Типизированные ошибки проекта несут данные все до одной:
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `contract.LostAcquisitionError` (идентификатор задачи).
Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
как случай, а не как живой код.
## Граница и трансляция: приватный и публичный канал
Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**, и
форма зависит от канала и от того, кто его видит:
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
`%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
найти полную ошибку в логах. «При обработке задачи произошла ошибка, job_id
= …», а не «произошла ошибка» и не сырой текст.
**Ключ есть не у всякого транспорта, и это называется вслух.** Отказ приёма
случается до заведения задачи, и ключа у него нет вовсе — тогда сообщение
остаётся без якоря, а диагностика ищется по записи доменной границы.
Заводить транспорту собственный идентификатор запроса ради ключа — решение
уровня спеки, а не умолчание;
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
HTTP и веба:
| Доменная ошибка | Статус | Сообщение |
| --- | --- | --- |
| задача не найдена | 404 | «задача не найдена» |
| файл не приложен, формат не распознан | 400 | «некорректный ввод» |
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» |
| прочее | 500 | «внутренняя ошибка» |
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go`
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на
любую ошибку заведения задачи.
### Разовый ответ и сохранённая диагностика
У публичной границы две поверхности, и правило сырого текста для них разное.
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок
авторизации. Ошибка транспорта может нести URL с токеном внутри, и её
вычищают на границе клиента;
- это **не** канал для разовых отказов — те остаются нейтральными;
- **внешнее значение в тексте усекается на границе, а его размер называется
числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
машинного текста — эту часть правила держит спека `intake`.
## panic
- `panic` — только для невосстановимого: нарушенный инвариант, ошибка
инициализации, из которой нельзя стартовать.
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber его вешает роутер хранилища сам
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
шаге конвейера роняет процесс целиком.
## Несколько ошибок
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.