остальные конвенции переведены на формальный язык
- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у каждого модальность и обязательный блок «Почему» - классифицирующие места оформлены таблицами, файловый статус снят отовсюду, локальные регионы сохранены под прежними именами
This commit is contained in:
+283
-101
@@ -1,140 +1,322 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Ошибки
|
||||
|
||||
Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
|
||||
**логировать** — в `lang/go/logging.md`, раздел «Ошибки» (коротко: лог один
|
||||
раз на доменной границе).
|
||||
Как ошибки строятся, оборачиваются и проверяются. Форма записи —
|
||||
`common/language.md`. Где и когда ошибку **логировать** — в
|
||||
`lang/go/logging.md` (коротко: лог один раз на доменной границе).
|
||||
|
||||
## Базовая идиома: stdlib
|
||||
## Правила
|
||||
|
||||
- Только стандартный `errors` + `fmt.Errorf`. Контекст ошибки несёт `slog`,
|
||||
а не стек: при дисциплине «каждый слой добавляет свой контекст» цепочка
|
||||
сообщений локализует место не хуже стека, а стек-трейсы и Sentry
|
||||
избыточны для домашнего сервиса.
|
||||
- Если отладка начнёт упираться в «где именно родилась ошибка» — это
|
||||
сигнал пересмотреть решение, а не дефолт, который можно обойти локально.
|
||||
- Единственное исключение — восстановленная паника: у неё цепочки `%w` нет
|
||||
вовсе (см. «panic»).
|
||||
### R1. Ошибки строятся средствами стандартной библиотеки
|
||||
|
||||
## Обёртка и контекст
|
||||
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
|
||||
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
|
||||
|
||||
Сервис — **приложение, а не библиотека**: внешнего Go-API нет, весь код
|
||||
наш. Возражение против дефолтного `%w` («обёрнутая ошибка становится частью
|
||||
API») относится к библиотекам, поэтому внутри приложения обёртка `%w` —
|
||||
**дефолт**, чтобы `errors.Is` и `errors.As` работали сквозь слои.
|
||||
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места.
|
||||
При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений
|
||||
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
|
||||
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
|
||||
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
|
||||
это цена без покупателя.
|
||||
|
||||
- Добавляем контекст обёрткой: `fmt.Errorf("parse magnet: %w", err)`.
|
||||
- `%w` — когда вызывающий может инспектировать причину (обычный случай).
|
||||
`%v` — когда причину сознательно **не** раскрываем, чтобы не завязывать
|
||||
вызывающего на чужой тип ошибки.
|
||||
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в
|
||||
цепочке, а трансляцией на внешней границе (ниже).
|
||||
Единственное место, где стек всё-таки нужен, — восстановленная паника: у
|
||||
неё цепочки `%w` нет вовсе (R23).
|
||||
|
||||
Стиль сообщения:
|
||||
### R2. Дефолт не обходится точечно
|
||||
|
||||
- со строчной буквы, без точки в конце, без «failed to» и «error» — обёртка
|
||||
и так читается как «контекст: причина»;
|
||||
- контекст — операция или субъект: `"link target: %w"`, не
|
||||
`"something failed"`;
|
||||
- без заикания: каждый слой добавляет **свой** смысл, не повторяя нижний
|
||||
(`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`).
|
||||
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
|
||||
кодовой базы ради конкретной отладки.
|
||||
|
||||
**Почему.** В коде появляются два способа устроить ошибку, и вызывающий
|
||||
перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
|
||||
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
|
||||
локально, перестаёт накапливаться — а накопление и есть единственный
|
||||
сигнал, что решение R1 пора пересматривать целиком.
|
||||
|
||||
### R3. Каждый слой добавляет свой контекст
|
||||
|
||||
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
|
||||
контекстом: `fmt.Errorf("parse magnet: %w", err)`.
|
||||
|
||||
**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько,
|
||||
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
|
||||
стирает участок пути — по итоговому сообщению нельзя сказать, через какую
|
||||
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
|
||||
кода.
|
||||
|
||||
### R4. Обёртка по умолчанию — `%w`
|
||||
|
||||
**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину
|
||||
вызывающему:
|
||||
|
||||
| № | Ситуация | Глагол |
|
||||
|---|---|---|
|
||||
| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
|
||||
| R4.2 | причину сознательно не раскрываем | `%v` |
|
||||
|
||||
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка
|
||||
становится частью API» — относится к библиотекам с внешними потребителями.
|
||||
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
|
||||
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
|
||||
`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у
|
||||
(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
|
||||
`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда
|
||||
завязывать вызывающего на чужой тип ошибки не хотят намеренно.
|
||||
|
||||
### R5. Утечка внутренних деталей лечится трансляцией, а не `%v`
|
||||
|
||||
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
|
||||
ошибку наружу.
|
||||
|
||||
**Почему.** Обрыв цепочки внутри кода не мешает тексту уехать наружу
|
||||
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
|
||||
детали утекут при любом глаголе. Подмена не решает задачу, ради которой
|
||||
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
|
||||
Настоящее место защиты — R13.
|
||||
|
||||
### R6. Текст обёртки — со строчной буквы и без служебных слов
|
||||
|
||||
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
|
||||
|
||||
**Почему.** Цепочка склеивается в одну строку через `": "`, и обёртка
|
||||
читается как «контекст: причина» — заглавные буквы и точки рвут эту строку
|
||||
на середине. Слова «failed» и «error» не несут информации: то, что перед
|
||||
нами ошибка, известно из того, что это ошибка. Зато повторяются они на
|
||||
каждом уровне и вытесняют из строки полезный контекст.
|
||||
|
||||
### R7. Контекст обёртки называет операцию или субъект
|
||||
|
||||
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
|
||||
|
||||
**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something
|
||||
failed» не сужает ничего и при этом занимает в сообщении место, которое мог
|
||||
бы занять единственный полезный здесь факт — имя операции.
|
||||
|
||||
### R8. Слой не повторяет смысл нижнего
|
||||
|
||||
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
|
||||
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
|
||||
|
||||
**Почему.** Повтор удлиняет сообщение, не добавляя локализации: одно и то
|
||||
же событие названо дважды. Читателю приходится проверять, не два ли это
|
||||
разных места в коде, — то есть заикание не просто бесполезно, оно стоит
|
||||
времени при каждом чтении лога.
|
||||
|
||||
## Две трансляции
|
||||
|
||||
Ошибка меняет форму дважды, и это разные преобразования.
|
||||
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная →
|
||||
доменная у источника (R9) и доменная → пользовательская на внешней границе
|
||||
(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
|
||||
|
||||
**Первая — у источника, инфраструктурная → доменная.** Граничные ошибки
|
||||
зависимостей транслируем там, где они возникли: `sql.ErrNoRows` → доменный
|
||||
`store.ErrNotFound` в слое store, чтобы выше по коду не торчал
|
||||
`database/sql`. То же для HTTP-клиентов, файловой системы, внешних SDK.
|
||||
### R9. Инфраструктурная ошибка транслируется в доменную у источника
|
||||
|
||||
**Вторая — на внешней границе, доменная → пользовательская.** Описана
|
||||
ниже, в разделе про каналы.
|
||||
**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где
|
||||
возникла: `sql.ErrNoRows` → `store.ErrNotFound` в слое store; то же для
|
||||
HTTP-клиентов, файловой системы, внешних SDK.
|
||||
|
||||
## Sentinel vs типизированные
|
||||
**Почему.** Иначе тип зависимости становится частью контракта всех слоёв
|
||||
выше: чтобы отличить «нет записи», доменный код импортирует `database/sql`
|
||||
и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не
|
||||
адаптер, а все ветвления в приложении — притом что снаружи адаптера
|
||||
состояние «нет записи» одно и то же. Трансляция у источника оставляет
|
||||
знание о зависимости в единственном слое, который её и так знает.
|
||||
|
||||
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий,
|
||||
на которые ветвится код: нет записи, дубликат, неподдерживаемый источник.
|
||||
Проверяем `errors.Is`.
|
||||
- **Типизированная ошибка** (тип с полями и методом `Error()`) — когда
|
||||
вызывающему нужны **данные** ошибки: поле валидации, код, лимит. Достаём
|
||||
`errors.As`. Не плодим типы там, где хватает sentinel.
|
||||
- Матчинг по тексту сообщения запрещён — это то же самое, что публичный
|
||||
API из строки лога.
|
||||
### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
|
||||
|
||||
## Граница: приватный канал vs публичный
|
||||
**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так:
|
||||
|
||||
| № | Что нужно вызывающему | Форма |
|
||||
|---|---|---|
|
||||
| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
|
||||
| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
|
||||
|
||||
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от
|
||||
структуры ошибки и переживает добавление полей. Тип заводится ради данных,
|
||||
и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой
|
||||
объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на
|
||||
каждой проверке. Две формы для одного условия — это два способа его
|
||||
проверить, и про второй рано или поздно забудут.
|
||||
|
||||
### R11. Матчинг по тексту сообщения
|
||||
|
||||
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
|
||||
|
||||
**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его
|
||||
свободно. Правка формулировки в нижнем слое молча ломает ветвление
|
||||
наверху, и компилятор этого не видит. Это то же самое, что публичный API из
|
||||
строки лога.
|
||||
|
||||
## Граница: приватный канал и публичный
|
||||
|
||||
Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от
|
||||
того, кто канал видит.
|
||||
того, кто канал видит: приватный канал — логи (их читает владелец сервиса),
|
||||
публичный — пользовательские поверхности (HTTP API, web-UI, бот).
|
||||
|
||||
**Приватный канал — логи** (владелец сервиса). Полная ошибка со всей
|
||||
цепочкой `%w` и контекстом. Пишется один раз на доменной границе.
|
||||
### R12. Полная ошибка идёт в приватный канал
|
||||
|
||||
**Публичный канал — пользовательские поверхности** (HTTP API, web-UI, бот).
|
||||
Сюда отдаём:
|
||||
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
|
||||
— `lang/go/logging.md`.
|
||||
|
||||
- **человекочитаемое сообщение** по доменной ошибке — не сырой
|
||||
`err.Error()` и не детали реализации (`database/sql`, пути, стек);
|
||||
- **корреляционный ключ** для владельца — id сущности либо `request_id`,
|
||||
чтобы по нему найти полную ошибку в логах. «При обработке загрузки
|
||||
произошла ошибка, download_id=…» вместо «произошла ошибка»;
|
||||
- **маппинг доменной ошибки → сообщение и, для HTTP, статус** — в одной
|
||||
точке на все транспорты. У транспортов без статусов (бот) от маппинга
|
||||
берётся только сообщение.
|
||||
**Почему.** Цепочка — единственный носитель диагностики (R1), и
|
||||
единственный канал, где её можно показать целиком, — тот, который видит
|
||||
владелец. Не записанная там, она не сохранится нигде: наружу идёт
|
||||
нейтральное сообщение (R13), и восстанавливать причину будет не из чего.
|
||||
|
||||
Новую штатную ветвь отказа (конфликт, валидация) заводим sentinel'ом и
|
||||
**сразу добавляем в маппинг** — иначе `default` отдаст 500 «внутренняя
|
||||
ошибка» на нормальный конфликт, а логирующая граница спишет его в `ERROR`
|
||||
вместо `DEBUG`.
|
||||
### R13. Публичная поверхность получает сообщение по доменной ошибке
|
||||
|
||||
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
|
||||
`err.Error()` и не детали реализации (`database/sql`, пути, стек).
|
||||
|
||||
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
|
||||
— у него есть лог (R12). Зато они раскрывают устройство системы — имена
|
||||
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
|
||||
причём раскрывают именно в момент, когда что-то пошло не так.
|
||||
|
||||
### R14. Публичное сообщение несёт корреляционный ключ
|
||||
|
||||
**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`:
|
||||
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
|
||||
ошибка».
|
||||
|
||||
**Почему.** R13 забирает у пользователя всю фактуру; без ключа его
|
||||
обращение звучит как «у меня что-то не работает», и владелец ищет запись в
|
||||
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
|
||||
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
|
||||
видел.
|
||||
|
||||
### R15. Маппинг доменных ошибок — в одной точке на все транспорты
|
||||
|
||||
**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус»
|
||||
задаётся один раз; транспорт без статусов (бот) берёт из него только
|
||||
сообщение.
|
||||
|
||||
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
|
||||
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
|
||||
важнее: единственная точка — это место, куда механически дописывается новая
|
||||
ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде»
|
||||
ничем не проверяет.
|
||||
|
||||
### R16. Новая штатная ветвь отказа сразу попадает в маппинг
|
||||
|
||||
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
|
||||
добавляется в маппинг (R15) тем же изменением.
|
||||
|
||||
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
|
||||
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
|
||||
его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают
|
||||
шуметь в логе ровно там, где по нему ищут настоящие поломки.
|
||||
|
||||
<!-- local:маппинг -->
|
||||
<!-- /local -->
|
||||
|
||||
### Транзиентный ответ vs персистентная диагностика
|
||||
### R17. Форма текста определяется поверхностью
|
||||
|
||||
У публичной границы две разные поверхности, и правило сырого текста для них
|
||||
разное:
|
||||
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
|
||||
текста для них разное:
|
||||
|
||||
- **Транзиентный ответ на действие** (тело ответа, `?err=`, реплика бота по
|
||||
результату команды) — строго нейтральный: маппинг выше, `err.Error()`
|
||||
наружу не идёт, полная ошибка живёт в логах по корреляционному ключу.
|
||||
- **Персистентная диагностика состояния** — причина ухода записи в
|
||||
ошибочное состояние, сохранённая в БД и показываемая оператору. Здесь
|
||||
сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) допустим и
|
||||
полезен — **но только пока поверхность видит исключительно владелец**.
|
||||
Появился второй зритель или публичный доступ к экрану состояния —
|
||||
поверхность стала публичным каналом, и правило нейтрального текста
|
||||
распространяется на неё. Секреты запрещены абсолютно в обоих случаях;
|
||||
источник вычищается на границе клиента.
|
||||
| № | Поверхность | Текст ошибки |
|
||||
|---|---|---|
|
||||
| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт |
|
||||
| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
|
||||
|
||||
Различие работает, только если поверхности не смешиваются в одном поле.
|
||||
Диагностику кладём в **отдельное поле**, а не в доменное.
|
||||
Появился второй зритель или публичный доступ к экрану состояния —
|
||||
поверхность стала публичным каналом, и на неё распространяется R17.1.
|
||||
|
||||
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
|
||||
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
|
||||
диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот
|
||||
эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла
|
||||
ошибка» в таком поле не несёт ничего и делает поле бессмысленным. Условие
|
||||
про единственного зрителя — ровно то, что делает вторую поверхность
|
||||
приватным каналом; без него это обычная публичная поверхность.
|
||||
|
||||
### R18. Секретов нет ни на одной из поверхностей
|
||||
|
||||
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
|
||||
ни в персистентную диагностику; источник вычищается на границе клиента.
|
||||
|
||||
**Почему.** Запрет абсолютен, потому что персистентная диагностика живёт в
|
||||
БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию
|
||||
самого секрета. Вычистка на границе клиента — единственное место, где ещё
|
||||
известно, какие поля запроса секретны: дальше ошибка едет как текст, и
|
||||
отличить в нём токен от идентификатора уже нельзя.
|
||||
|
||||
### R19. Диагностика хранится в отдельном поле
|
||||
|
||||
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
|
||||
показывают пользователю.
|
||||
|
||||
**Почему.** Различие R17.1 и R17.2 держится на том, что у поверхностей
|
||||
разные поля. Одно поле на оба назначения означает, что при первом же показе
|
||||
записи наружу сырой текст уедет туда же — не по решению, а потому что поле
|
||||
одно.
|
||||
|
||||
## panic
|
||||
|
||||
- `panic` — только для невосстановимого: нарушенный инвариант (баг
|
||||
программиста), ошибка инициализации, из которой нельзя стартовать.
|
||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
|
||||
ввод) — это значения `error`.
|
||||
- **`recover` — на верхней границе каждой обрабатывающей единицы**, а не
|
||||
только у HTTP:
|
||||
- HTTP middleware — `net/http` сам восстанавливает панику в хендлере и
|
||||
процесс не роняет, поэтому смысл своего `recover` в другом: отдать
|
||||
контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер;
|
||||
- цикл обработки апдейтов бота и фоновый воркер — вот здесь паника в
|
||||
горутине **действительно роняет процесс**, и `recover` обязателен.
|
||||
`recover` работает только в той горутине, где случилась паника.
|
||||
- **Логирующая recover-граница пишет `debug.Stack()`.** Это единственное
|
||||
место, где нужен стек-трейс: у восстановленной паники нет цепочки `%w`, и
|
||||
без стека «index out of range» не диагностируется вообще.
|
||||
### R20. `panic` — только для невосстановимого
|
||||
|
||||
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
|
||||
ошибка инициализации, из которой нельзя стартовать.
|
||||
|
||||
**Почему.** Паника не оставляет вызывающему выбора: обработать её на месте
|
||||
нельзя, можно только уронить единицу обработки. Это верный ответ, когда
|
||||
состояние процесса перестало описываться кодом: работа с нарушенным
|
||||
инвариантом опаснее падения, а сервис, стартовавший без обязательной
|
||||
зависимости, всё равно откажет позже и непонятнее.
|
||||
|
||||
### R21. Ожидаемые ошибки — значения `error`
|
||||
|
||||
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
|
||||
плохой ввод, отсутствующая запись возвращаются как `error`.
|
||||
|
||||
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном
|
||||
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
|
||||
его обработать. Дальше такая паника долетает до recover-границы (R22), где
|
||||
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
|
||||
«мы сломались».
|
||||
|
||||
### R22. `recover` — на верхней границе каждой обрабатывающей единицы
|
||||
|
||||
**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный:
|
||||
|
||||
| № | Единица | Зачем `recover` |
|
||||
|---|---|---|
|
||||
| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
|
||||
| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
|
||||
|
||||
**Почему.** `recover` работает только в той горутине, где случилась паника,
|
||||
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
|
||||
каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с
|
||||
неожиданным полем гасят весь сервис, включая части, к этой ошибке
|
||||
отношения не имеющие. У HTTP цена бездействия ниже, но не нулевая: паника
|
||||
без своего `recover` уходит мимо структурированного лога, а клиент получает
|
||||
оборванное соединение вместо ответа.
|
||||
|
||||
### R23. Recover-граница пишет `debug.Stack()`
|
||||
|
||||
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
|
||||
|
||||
**Почему.** Это единственное место, где стек нужен (R1): у восстановленной
|
||||
паники цепочки `%w` нет вовсе. «index out of range» без стека не
|
||||
диагностируется в принципе — сообщение не называет ни файла, ни операции,
|
||||
по нему нельзя сказать даже, в каком пакете упало.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
Сбор независимых ошибок (валидация конфига — все проблемы разом) —
|
||||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
||||
### R24. Независимые ошибки собираются `errors.Join`
|
||||
|
||||
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
|
||||
разом; проверка собранного — по-прежнему через `errors.Is`.
|
||||
|
||||
**Почему.** Возврат первой ошибки превращает починку конфига в серию
|
||||
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
|
||||
тот же список, но убивает ветвление: `errors.Is` по такому результату не
|
||||
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
|
||||
(R11).
|
||||
|
||||
## Связано
|
||||
|
||||
- `lang/go/logging.md` — где и когда ошибка попадает в лог.
|
||||
- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
Reference in New Issue
Block a user