ADR-0066: Классификация ошибок только по зарегистрированному коду, не по тексту сообщения
Status: accepted Date: 2026-07-08 Deciders: Maxim Belkanov
Контекст
Платформа уже даёт типизированную классификацию ошибок (internal/platform/errors):
Code-константы + реестр registry map[Code]Definition (registry.go, метаданные:
категория, HTTP-статус, log-level), конструкторы New(ctx, Code, opts…) /
Wrap(ctx, cause, Code, opts…), и проверки HasCode(err, Code) / (*Error).Code() /
errors.Is (fallback на сравнение по Code, errors.go:209).
Несмотря на это, встречается ветвление по тексту сообщения —
strings.Contains(err.Error(), "…"). Это плохо по трём причинам:
- Хрупко: текст сообщения — человекочитаемый и может меняться/локализоваться; изменение формулировки молча ломает логику.
- Неотслеживаемо: по строке
"partial_success"невозможно найти, где ошибка формируется — обработчик исхода и источник ошибки не связаны grep-ом. Ревьюер не может проверить корректность. - Обходит реестр: исход не проходит через
Definition(категория, статус, уровень лога), типизированный контракт на границе API рассыпается.
Триггер: интеграционный тест контура russvet допускал частичный исход поставщика
через strings.Contains(err.Error(), "partial_success") вместо кода
CodeIngestionConnectorPartialSuccess, уже существующего в реестре.
Решение
- Каждая доменно-значимая ошибка создаётся через
platformerrors.New/Wrap(ctx, Code, …)с зарегистрированнойCode-константой. Новый исход = новая константа + запись вregistry(Definition), а не свободный текст. - Ветвление и проверка исхода — только по коду:
platformerrors.HasCode(err, CodeX)илиerrors.Is(err, платформенная-ошибка-с-этим-кодом). Для инфраструктурных ошибок сторонних библиотек — по их типизированным кодам (напр.pgconn.PgError.Code == "23505",errors.Is(err, pgx.ErrNoRows)), не по подстроке. - Запрещено управлять потоком по
err.Error()—strings.Contains/HasPrefix/HasSuffix/Indexпо тексту ошибки. Текст сообщения — для людей и логов, не для логики. - Существующие нарушения — в рефакторинг-долг (см. Последствия), сокращать, не расширять. Новый код обязан следовать инварианту сразу.
Последствия
- Обработчик исхода находится grep-ом по константе кода; текст сообщения можно менять и локализовать, не трогая логику; типизированный envelope на границе API консистентен.
- Долг до-ADR (отдать на рефакторинг, каждый — со своим зарегистрированным кодом):
cmd/supplier-sync/main.go:534—"bulk snapshot pipeline".catalog/characteristic/infra/postgres/provisioner_repo.go:211—"23505"(→pgconn.PgError.Code).catalog/canonical/api/http/dispute_moderation_handler.go:210—"no rows"(уже естьerrors.Is(pgx.ErrNoRows); убрать строковый fallback).catalog/canonical/api/http/dispute_admin_handler.go:418—"missing or in_progress".catalog/canonical/api/http/dispute_admin_handler.go:458—"no rows".catalog/canonical/api/http/dispute_admin_handler.go:654—"missing or in terminal".
- Энфорсмент (предложено): расширить статический анализ (errorlint /
cmd/layerlint) правилом «ветвление поerr.Error()запрещено» с allowlist для перечисленного долга (по мере рефакторинга — сокращать). До появления правила инвариант держится ревью.
Связано
internal/platform/errors/{errors.go,registry.go}— механизм (Code, Definition, New/Wrap, HasCode).- ADR-0030 (композиция/контракты), ADR-0064 (чистые слои) — типизированные контракты вместо строковых ключей; ADR-0065 — тот же принцип для supplier-литералов (типизированный ref вместо строк + layerlint).