- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
169 lines
14 KiB
Markdown
169 lines
14 KiB
Markdown
# Ошибки
|
||
|
||
Конвенция: *как* устроены и передаются ошибки в 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 и веба:
|
||
|
||
| Доменная ошибка | Статус | `error_code` | Сообщение |
|
||
| --- | --- | --- | --- |
|
||
| сессии нет | 401 | `unauthorized` | «требуется вход» |
|
||
| предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» |
|
||
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
|
||
| файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» |
|
||
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
|
||
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
|
||
| текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
|
||
| прочее | 500 | `internal` | «внутренняя ошибка» |
|
||
|
||
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
|
||
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
|
||
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
|
||
|
||
**Тело отказа несёт два поля — `error_code` и `message`.** Кода HTTP не
|
||
хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три
|
||
`400`, а приложению надо решать, предлагать ли повтор. Разбор русской фразы
|
||
был бы единственным оставшимся путём. Норму держит спека `archive`.
|
||
|
||
Точка живёт в `internal/controller/http.mapDomainError` и названа в
|
||
[architecture.md](../architecture.md), «Единые точки проекта». Прежнее
|
||
расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей
|
||
`json-api-for-spa` 2026-08-15.
|
||
|
||
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
|
||
частоты, неизвестный путь под корнем приложения — и до этой точки не доходит
|
||
вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех
|
||
прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети
|
||
приходил бы телом библиотеки.
|
||
|
||
### Разовый ответ и сохранённая диагностика
|
||
|
||
У публичной границы две поверхности, и правило сырого текста для них разное.
|
||
|
||
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||
полная ошибка остаётся в логах по идентификатору задачи.
|
||
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
|
||
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
||
и полезен. Но:
|
||
- **секреты запрещены** — токены, ключи, пароли, заголовок
|
||
авторизации. Ошибка транспорта может нести URL с токеном внутри, и её
|
||
вычищают на границе клиента;
|
||
- это **не** канал для разовых отказов — те остаются нейтральными;
|
||
- **внешнее значение в тексте усекается на границе, а его размер называется
|
||
числом рядом**: без этого непонятно, насколько сокращать.
|
||
|
||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
|
||
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
|
||
машинного текста — эту часть правила держит спека `intake`.
|
||
|
||
## panic
|
||
|
||
- `panic` — только для невосстановимого: нарушенный инвариант, ошибка
|
||
инициализации, из которой нельзя стартовать.
|
||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
||
это значения `error`.
|
||
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
||
ронял процесс. В transcriber его вешает роутер хранилища сам
|
||
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
|
||
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
|
||
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
|
||
шаге конвейера роняет процесс целиком.
|
||
|
||
## Несколько ошибок
|
||
|
||
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
|
||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|