Files
transcriber/docs/conventions/errors.md
T
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00

15 KiB
Raw Blame History

Ошибки

Конвенция: как устроены и передаются ошибки в transcriber. Правила оформления кода (How). Где и когда ошибку логировать — в logging.md, раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как ошибки строятся, оборачиваются и проверяются.

Взято из проекта jellybit. Расхождения с сегодняшним кодом названы по месту. Главное: единой точки отображения доменной ошибки в ответ нет, обработчики решают сами.

Механизировано: приведение типа и err == ErrX ловит errorlint, сторонние пакеты ошибок — depguard, узнавание ошибки по тексту сообщения — тест-сканер internal/archrules. Перечень и адреса — 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.

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.
  • Публичный канал — пользовательские поверхности (веб-UI, HTTP API). Сюда отдаём:
    • человекочитаемое сообщение по доменной ошибке — не сырой err.Error() и не детали реализации (database/sql, пути на диске, имена внешних сервисов);

    • корреляционный ключ для владельца — идентификатор задачи, чтобы по нему найти полную ошибку в логах. «При обработке задачи произошла ошибка, job_id = …», а не «произошла ошибка» и не сырой текст.

      Ключ есть не у всякого транспорта, и это называется вслух. Отказ приёма случается до заведения задачи, и ключа у него нет вовсе — тогда сообщение остаётся без якоря, а диагностика ищется по записи доменной границы. Заводить транспорту собственный идентификатор запроса ради ключа — решение уровня спеки, а не умолчание;

    • отображение доменной ошибки в статус и сообщение — единой точкой для HTTP и веба:

      Доменная ошибка Статус error_code Сообщение
      пришедший не узнан 401 unauthorized «сервис вас не узнал»
      запись не найдена, чужая либо ничья 404 not_found «запись не найдена»
      файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон 400 bad_request «некорректный ввод»
      запись сверх потолка размера 413 too_large «запись больше допустимого размера», плюс предел числом
      запросов слишком много подряд 429 too_many_requests «слишком много запросов подряд, попробуйте позже»
      текста или копии файла запрошенного вида ещё нет 409 not_ready «действие недоступно в текущем состоянии»
      прочее 500 internal «внутренняя ошибка»

      Ветвь 403/forbidden ушла отсюда 2026-08-22 вместе со своим единственным случаем: им был владелец панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку учётную запись заводит само.

      Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а логирующая граница спишет его в ERROR вместо DEBUG.

      Тело отказа несёт два поля — error_code и message. Кода HTTP не хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три 400, а приложению надо решать, предлагать ли повтор. Разбор русской фразы был бы единственным оставшимся путём. Норму держит спека archive.

      Точка живёт в internal/controller/http.mapDomainError и названа в architecture.md, «Единые точки проекта». Прежнее расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей json-api-for-spa 2026-08-15.

      Часть отказов рождается не в обработчике — предел тела, ограничитель частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них отвечает своей доменной ошибкой через ту же точку. Прежде их приводил к общей форме слой OneErrorForm, стоявший снаружи всех прочих и переводивший тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться неоткуда.

Разовый ответ и сохранённая диагностика

У публичной границы две поверхности, и правило сырого текста для них разное.

  • Разовый ответ на действие (тело HTTP-ответа) — строго нейтральный: отображение выше, err.Error() наружу не идёт, полная ошибка остаётся в логах по идентификатору задачи.

  • Сохранённая диагностика состояния — колонка error_text аудиозаписи. Это поверхность владельца, а не пользователя: сюда сырой текст ошибки допустим и полезен. Но:

    • секреты запрещены — токены, ключи, пароли, заголовок авторизации. Ошибка транспорта может нести URL с токеном внутри, и её вычищают на границе клиента;
    • это не канал для разовых отказов — те остаются нейтральными;
    • внешнее значение в тексте усекается на границе, а его размер называется числом рядом: без этого непонятно, насколько сокращать.

    Расхождение: error_text пишется целиком, без вычистки и без усечения. Наружу он при этом не выходит: карточка записи отдаёт причину остановки без машинного текста — эту часть правила держит спека archive.

panic

  • panic — только для невосстановимого: нарушенный инвариант, ошибка инициализации, из которой нельзя стартовать.
  • Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) — это значения error.
  • recover — на верхней границе обработчика, чтобы один паникующий запрос не ронял процесс. В transcriber его ставит свой слой http.Recover: паникующий обработчик отдаёт 500 нашей формой тела, а строка о панике идёт в журнал владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала чужая библиотека. У воркеров такой границы нет: паника в шаге конвейера роняет процесс целиком.

Несколько ошибок

  • Сбор независимых ошибок (проверка конфига — все проблемы разом) — errors.Join; проверка собранного по-прежнему через errors.Is.

Расхождение: проверку конфига пункт называет поимённо, а ни одна из них так не устроена: errors.Join в internal/config не зовётся нигде, и всякая проверка возвращается на первом несовпадении. Заметило ревью задачи config-test-headers-login 2026-08-23 — тем же прогоном, каким добавили ValidateTestHeaders, ведущую себя так же. Человек, заполняющий конфиг впервые, чинит одну ошибку за прогон.