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(), "…"). Это плохо по трём причинам:

  1. Хрупко: текст сообщения — человекочитаемый и может меняться/локализоваться; изменение формулировки молча ломает логику.
  2. Неотслеживаемо: по строке "partial_success" невозможно найти, где ошибка формируется — обработчик исхода и источник ошибки не связаны grep-ом. Ревьюер не может проверить корректность.
  3. Обходит реестр: исход не проходит через Definition (категория, статус, уровень лога), типизированный контракт на границе API рассыпается.

Триггер: интеграционный тест контура russvet допускал частичный исход поставщика через strings.Contains(err.Error(), "partial_success") вместо кода CodeIngestionConnectorPartialSuccess, уже существующего в реестре.

Решение

  1. Каждая доменно-значимая ошибка создаётся через platformerrors.New/Wrap(ctx, Code, …) с зарегистрированной Code-константой. Новый исход = новая константа + запись в registry (Definition), а не свободный текст.
  2. Ветвление и проверка исхода — только по коду: platformerrors.HasCode(err, CodeX) или errors.Is(err, платформенная-ошибка-с-этим-кодом). Для инфраструктурных ошибок сторонних библиотек — по их типизированным кодам (напр. pgconn.PgError.Code == "23505", errors.Is(err, pgx.ErrNoRows)), не по подстроке.
  3. Запрещено управлять потоком по err.Error()strings.Contains/HasPrefix/HasSuffix/Index по тексту ошибки. Текст сообщения — для людей и логов, не для логики.
  4. Существующие нарушения — в рефакторинг-долг (см. Последствия), сокращать, не расширять. Новый код обязан следовать инварианту сразу.

Последствия

  • Обработчик исхода находится 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).