Ошибки API: единый контур apperr
Как API сообщает об ошибках — единый формат ответа, стабильные коды и сообщения на русском и английском.
Формат ответа
Все ошибки API возвращаются в одном конверте — он не меняется:
{
"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):
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 …)):
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:
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-статусы
Kind | HTTP | Назначение |
|---|---|---|
KindInternal | 500 | Внутренняя ошибка / мисконфигурация |
KindValidation | 400 | Ошибка валидации поля |
KindInvalid | 400 | Некорректный ввод / значение |
KindInvalidStatus | 400 | Недопустимое значение статуса |
KindUnauthorized | 401 | Не аутентифицирован |
KindForbidden | 403 | Нет прав / доступа |
KindForbiddenTransition | 403 | Недопустимый переход состояния |
KindNotFound | 404 | Сущность не найдена |
KindConflict | 409 | Конфликт состояния |
Совместимость с 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-сообщениями.