errors: непокрытая маппингом ошибка и поведение после recover

- R25: 500 и ERROR с признаком непокрытой — без признака забытая ветвь
  неотличима в логах от упавшей базы
- R26: HTTP-запрос завершается 500, цикл продолжается со следующего
  элемента, а упавший выводится из оборота — иначе poison message
This commit is contained in:
av
2026-07-25 20:15:36 +03:00
parent 9085601db2
commit 477499877d
2 changed files with 59 additions and 0 deletions
+55
View File
@@ -219,6 +219,25 @@ HTTP-клиентов, файловой системы, внешних SDK.
<!-- local:маппинг -->
<!-- /local -->
### R25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком
**ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (R15) нет ветви, отдаёт
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с
признаком того, что маппинг её не знает.
**Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
завести вопреки R16. Адресат у неё владелец в смысле «надо чинить», отсюда
`ERROR` — уровень выбирается по адресату (`lang/go/logging.md`). Статус
тоже не выбирается: известное пользовательское состояние лежало бы в
маппинге, а про неизвестное сказать пользователю нечего, поэтому 4xx
отпадает.
Признак нужен потому, что без него забытая ветвь неотличима от упавшей
базы: обе дают `ERROR` с текстом ошибки, и наткнуться на пропуск можно
только случайно. Отдельное поле или своя категория сообщения делают пропуск
находимым одним фильтром — и тогда громкость 500 и `ERROR` работает как
механизм обнаружения, а не как шум.
### R17. Форма текста определяется поверхностью
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
@@ -313,6 +332,42 @@ HTTP-клиентов, файловой системы, внешних SDK.
## Несколько ошибок
### R26. После `recover` единица продолжает работу, исключив упавшее
**ДОЛЖЕН.** Что происходит после перехвата, зависит от того, где стоит
граница:
| № | Где перехвачена паника | Что дальше |
|---|---|---|
| R26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
| R26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
**Почему.** Паника внутри обработки одного элемента почти всегда говорит о
баге в работе с данными этого элемента, а не о порче общего состояния, —
останавливать всё остальное не за что. Довод «let it crash» здесь работает
не буквально: в OTP падает изолированный процесс под супервизором, а не узел
целиком, и в Go ближайшая замена такой изоляции — граница итерации, а не
граница процесса. Обратное при этом верно и делает `recover` в цикле
обязательным (R22): неперехваченная паника в любой горутине завершает весь
процесс.
Продолжать, не исключив упавший элемент, нельзя: детерминированная паника
даёт бесконечный цикл — тот же элемент, тот же стек, залитый лог и нулевой
прогресс. Это классический poison message, и лекарство берём то же, что
принято в очередях: элемент выводится из оборота, а не берётся снова. У
цикла, который и так подтверждает прогресс — сдвигает офсет, помечает
строку состоянием, — механизм для этого уже есть, заводить отдельный не
нужно.
Оговорка «если ответ ещё не начат» в R26.1 не формальность: статус
отправляется один раз, и после первой записи в тело поменять его нечем —
клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать
ответ целиком до записи там, где это возможно.
Из R26.1 есть одно исключение: `http.ErrAbortHandler` — сигнал «прервать
обработку намеренно», и recover-обёртка пробрасывает его дальше, а не
превращает в 500. Так поступают и стандартные обёртки вроде chi.
### R24. Независимые ошибки собираются `errors.Join`
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
+4
View File
@@ -348,6 +348,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
таблицу не входит: это не доменный отказ, и логирует его recover-граница
вместе со стеком (`lang/go/errors.md`). Искать его класс здесь не нужно.
Мимо таблицы идёт и доменная ошибка, которой нет в маппинге: класса у неё
нет, потому что её просто забыли завести. Она логируется `ERROR` с
признаком непокрытой (`lang/go/errors.md` R25).
### R26. Тот же отказ в асинхронной стадии — уровнем выше
**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован