Skip to content

Ошибки API: единый контур apperr ​

Как API сообщает об ошибках — единый формат ответа, стабильные коды и сообщения на русском и английском.


Формат ответа ​

Все ошибки API возвращаются в одном конверте — он не меняется:

json
{
  "code": "projects_not_found",
  "message": "Проект не найден",
  "details": [
    { "field": "name", "message": "Обязательное поле" }
  ]
}
  • code — стабильный машинно-читаемый код вида <module>_<reason> (например projects_not_found, equipment_requests_quantity_invalid). Фронт может ветвиться по нему.
  • message — человекочитаемое сообщение на языке запроса (см. ниже). Дефолт — русский.
  • details — необязательный массив пояснений по полям (field + message); присутствует, когда ошибка привязана к конкретным полям ввода.

HTTP-статус соответствует категории ошибки (см. таблицу).

Язык сообщений ​

Язык выбирается по заголовку Accept-Language:

  • Accept-Language: en (или начинается с en) → сообщения на английском.
  • Иначе (в т.ч. ru, неизвестный язык, отсутствие заголовка) → русский.

Логика — transport.LangFromContext. Берётся первый (приоритетный) языковой тег, q-веса игнорируются.

Откуда берутся ошибки: пакет internal/apperr ​

Каждая ошибка — это package-level определение *apperr.Error, заданное один раз в каталоге модуля (internal/modules/<module>/errors.go):

go
var ErrProjectNotFound = apperr.New("projects_not_found", apperr.KindNotFound,
    apperr.WithText("ru", "Проект не найден"),
    apperr.WithText("en", "project not found"))

Сервис просто возвращает её: return nil, ErrProjectNotFound. Транспортный слой (transport.AbortServiceError) распознаёт *apperr.Error, берёт из неё HTTP-статус, код, сообщение на нужном языке и детали — и формирует конверт выше.

Динамические ошибки ​

Когда в сообщение нужно подставить значение — используйте apperr.Newf, который форматирует ru- и en-шаблоны одними и теми же аргументами (семантика fmt.Sprintf). Если ru должен показать подпись, а en — сырой код, передайте оба и сошлитесь на них явными индексами %[n]s (лишние аргументы при индексах не дают %!(EXTRA …)):

go
return apperr.Newf("tasks_status_transition_forbidden", apperr.KindForbiddenTransition,
    "Задачу нельзя перевести из статуса «%[3]s» в «%[4]s»", "forbidden transition: %[1]s -> %[2]s",
    from, to, taskStatusLabel(from), taskStatusLabel(to))

Когда нужно добавить пояснение по полю или переопределить текст — у *apperr.Error есть иммутабельные деривации (возвращают копию, не меняя package-level определение):

  • WithField(field, message) — добавить пояснение по полю в details.
  • WithText(lang, text) — переопределить текст для языка.

Если ru-текст не зависит от значения, а en должен его сохранить (для логов и тестов), заведите package-level определение и деривируйте en:

go
var errStatusInvalidDef = apperr.New("tasks_status_invalid", apperr.KindInvalidStatus,
    apperr.WithText("ru", "Неизвестный статус задачи"),
    apperr.WithText("en", "invalid status"))

func errStatusInvalid(status string) *apperr.Error {
    return errStatusInvalidDef.WithText("en", "invalid status: "+status)
}

apperr.New вызывается только на уровне пакета: каждое определение попадает в реестр apperr.Registered(), и вызов внутри функции раздувал бы его на каждый запрос. Для ошибок, собираемых во время запроса, — apperr.Newf или деривация.

Правила ru-текстов ​

Фронтенд показывает message пользователю как есть, поэтому ru-текст пишется в терминах интерфейса:

  • одно короткое предложение, как в подсказке формы: «Укажите счёт или кассу», «Плановое прибытие не может быть раньше плановой отправки»;
  • поля называются подписями из форм фронтенда, значения перечислений — подписями из src/data/dictionaries.ts и src/data/statuses.ts («Тип обеспечения: внутренний трансфер, закупка или аренда»);
  • никаких кодов полей и значений (entry_date, internal_transfer), id → «номер» или название сущности, email → «почта»;
  • латиница — только из короткого allowlist с обоснованием (Excel, Telegram — так они подписаны в интерфейсе).

Те же правила действуют для подсказок под полем — details[].message («Обязательное поле», «Не короче 3 символов», «Недопустимое значение»): фронтенд показывает их под полем формы как есть. Подсказки валидатора описаны в Поля и валидация. У package-level ошибки подсказку задают опцией apperr.WithDetail(field, message), чтобы она попала в реестр; WithField — для ошибок, собираемых во время запроса.

Правила (общие проверки — пакет internal/uitext) проверяет internal/app/error_texts_test.go (пакет internal/app импортирует все модули):

  • TestRegisteredErrorsHaveHumanRuTexts — обходит apperr.Registered() и проверяет ru-текст каждого определения: не пустой, не равен коду, есть кириллица, нет snake_case и латиницы вне allowlist;
  • TestNewfRuTemplatesAreHuman — разбирает internal/**/*.go через go/ast и проверяет по тем же правилам ru-шаблоны apperr.Newf (без плейсхолдеров %s, %[2]d…) и литералы .WithText("ru", …);
  • TestErrorTextsCatalogueIsComplete — каждый код apperr.New из исходников должен быть в реестре, иначе модуль выпал из графа импорта internal/app;
  • TestRegisteredErrorDetailsAreHuman — details всех зарегистрированных ошибок;
  • TestFieldHintsInSourcesAreHuman — литералы подсказок в transport.NewFieldError, WithField, apperr.WithDetail и ErrorDetail{Message: …}; нелитеральная подсказка (например, err.Error() или сырой статус) — нарушение, сквозные места помечаются комментарием uitext:checked с указанием, где текст проверен;
  • TestApperrNewOnlyAtPackageScope — apperr.New внутри функций и деривации WithField/WithText на уровне пакета запрещены.

Подсказки валидатора проверяет internal/transport/field_hints_test.go: прогоняет validationMessage по каждому правилу и падает, если в DTO появилось правило, которое тест не покрывает.

Падение перечисляет все нарушения сразу — с кодом, файлом и текстом.

Категории (Kind) и HTTP-статусы ​

KindHTTPНазначение
KindInternal500Внутренняя ошибка / мисконфигурация
KindValidation400Ошибка валидации поля
KindInvalid400Некорректный ввод / значение
KindInvalidStatus400Недопустимое значение статуса
KindUnauthorized401Не аутентифицирован
KindForbidden403Нет прав / доступа
KindForbiddenTransition403Недопустимый переход состояния
KindNotFound404Сущность не найдена
KindConflict409Конфликт состояния

Совместимость с legacy-кодами ​

Раньше все ошибки сводились к 6 generic-сентинелам в internal/modules/domain/errors.go и 7 generic-кодам (not_found, conflict, invalid_request, …). Контур мигрируется инкрементально, по модулю:

  • apperr.(*Error).Is маппит Kind обратно в соответствующий domain.Err*, поэтому существующие проверки errors.Is(err, domain.ErrNotFound) продолжают работать.
  • В transport.AbortServiceError сохранён прежний switch по domain.Err* как fallback.

Важно для фронта: значение code стало специфичным (projects_not_found вместо not_found). Форма ответа та же, статус тот же, но ветвление строго по code === 'not_found' больше не сработает — используйте HTTP-статус или новые namespaced-коды.

Покрытие ​

Все модули сервисного слоя переведены на apperr. Сознательно оставлены на legacy-domain.Err* (продолжают работать через fallback и errors.Is):

  • Проверки errors.Is(err, domain.Err*) — это сравнения, а не возвраты; apperr-ошибки им удовлетворяют.
  • Репозитории (*_pg.go, storage/) — возвращают канонический domain.ErrNotFound; маппинг в сообщение делает сервис/транспорт.
  • Обёртки fmt.Errorf("%w: %w", domain.Err*, err) — несколько мест, где к доменной ошибке подмешивается низкоуровневая; оставлены как есть.

Для bare-возвратов (return domain.ErrNotFound без сообщения) в сервисах заведены generic-фоллбэки в errors.go модуля (errNotFound, errForbidden и т.п.) с нейтральными ru/en-сообщениями.

Загружаем документацию…