Канон документов, каталог задач и OpenSpec
docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью. Конвенции перенесены из jellybit; места, где код им не следует, помечены строкой «Расхождение» как объявленный долг. tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и многопользовательский режим), два направления (все форматы, долгие записи) и пять задач в беклоге. openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет. CLAUDE.md переписан по форме канона: инварианты с severity, семантика гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
# Ошибки
|
||||
|
||||
Конвенция: *как* устроены и передаются ошибки в transcriber. Правила оформления
|
||||
кода (How). Где и когда ошибку **логировать** — в [logging.md](logging.md),
|
||||
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как ошибки
|
||||
строятся, оборачиваются и проверяются.
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
|
||||
решают сами, а доменные ошибки проверяются приведением типа, а не `errors.As`.
|
||||
|
||||
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в
|
||||
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних
|
||||
пакетов ошибок в проекте и так нет.
|
||||
|
||||
## Базовая идиома: 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`, а не сравнением и не приведением типа.
|
||||
|
||||
*Расхождение, и оно опасно:* `NoopJobError` и `JobNotFoundError` проверяются
|
||||
приведением типа — `err.(*contract.NoopJobError)` в
|
||||
`internal/controller/worker/worker.go` и `err.(*contract.JobNotFoundError)` в
|
||||
`internal/service/transcribe.go`. Работает это только потому, что на этом пути
|
||||
ошибку никто не оборачивает. Первый же `fmt.Errorf("…: %w")` между ними сломает
|
||||
проверку молча: воркер перестанет отличать «задач нет» от отказа и начнёт
|
||||
считать пустой прогон ошибкой раз в секунду.
|
||||
|
||||
## Sentinel и типизированные
|
||||
|
||||
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий, на
|
||||
которые ветвится код. Проверяем `errors.Is`.
|
||||
- **Типизированная ошибка** (тип с полями плюс метод `Error()`) — когда
|
||||
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
|
||||
где хватает sentinel.
|
||||
|
||||
Сегодня в проекте три типизированные ошибки, и данные несёт только одна:
|
||||
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
|
||||
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel).
|
||||
|
||||
## Граница и трансляция: приватный и публичный канал
|
||||
|
||||
Внутри — богатые обёрнутые ошибки. На внешней границе ошибку **транслируем**, и
|
||||
форма зависит от канала и от того, кто его видит:
|
||||
|
||||
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
|
||||
`%w` и контекстом. Пишется один раз на доменной границе — см.
|
||||
[logging.md](logging.md).
|
||||
- **Публичный канал — пользовательские поверхности** (Telegram, веб-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` пишется целиком, без вычистки и без усечения, а
|
||||
пользователь Telegram видит отдельный человекочитаемый текст — это часть
|
||||
правила соблюдена.
|
||||
|
||||
## panic
|
||||
|
||||
- `panic` — только для невосстановимого: нарушенный инвариант, ошибка
|
||||
инициализации, из которой нельзя стартовать.
|
||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
||||
это значения `error`.
|
||||
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
||||
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой
|
||||
границы **нет**: паника в шаге конвейера роняет процесс целиком.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
|
||||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
||||
Reference in New Issue
Block a user