diff --git a/conventions/lang/go/errors.md b/conventions/lang/go/errors.md index 2516b59..a5fa28b 100644 --- a/conventions/lang/go/errors.md +++ b/conventions/lang/go/errors.md @@ -219,6 +219,25 @@ HTTP-клиентов, файловой системы, внешних SDK. +### 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` **СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы diff --git a/conventions/lang/go/logging.md b/conventions/lang/go/logging.md index 35bdc6a..2fdfd22 100644 --- a/conventions/lang/go/logging.md +++ b/conventions/lang/go/logging.md @@ -348,6 +348,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд таблицу не входит: это не доменный отказ, и логирует его recover-граница вместе со стеком (`lang/go/errors.md`). Искать его класс здесь не нужно. +Мимо таблицы идёт и доменная ошибка, которой нет в маппинге: класса у неё +нет, потому что её просто забыли завести. Она логируется `ERROR` с +признаком непокрытой (`lang/go/errors.md` R25). + ### R26. Тот же отказ в асинхронной стадии — уровнем выше **ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован