From 477499877df5f2d814c77896f15a5b6827fc0d64 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 25 Jul 2026 20:15:36 +0300 Subject: [PATCH] =?UTF-8?q?errors:=20=D0=BD=D0=B5=D0=BF=D0=BE=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B0=D1=8F=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=BE=D0=BC=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20recover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R25: 500 и ERROR с признаком непокрытой — без признака забытая ветвь неотличима в логах от упавшей базы - R26: HTTP-запрос завершается 500, цикл продолжается со следующего элемента, а упавший выводится из оборота — иначе poison message --- conventions/lang/go/errors.md | 55 ++++++++++++++++++++++++++++++++++ conventions/lang/go/logging.md | 4 +++ 2 files changed, 59 insertions(+) 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. Тот же отказ в асинхронной стадии — уровнем выше **ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован