- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
14 KiB
Ошибки
Конвенция: как устроены и передаются ошибки в 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«требуется вход» предъявитель узнан, учётной записи пользователя нет 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, «Единые точки проекта». Прежнее расхождение — «такой точки нет, обработчик решает сам» — закрыто задачейjson-api-for-spa2026-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.