Обработка ошибок на фронте
Инструкция для фронтенда: как читать ошибки API и единый алгоритм их обработки после перехода бэкенда на контур apperr.
Контракт со стороны сервера описан в «Ошибки API (apperr)». Здесь — только то, что нужно клиенту.
Что изменилось (TL;DR)
- Форма ответа не менялась — тот же конверт
{ code, message, details? }, те же HTTP-статусы. codeстал специфичным: вместо 7 generic-кодов теперь namespaced-коды вида<module>_<reason>(projects_not_found,equipment_requests_quantity_invalid, …).- ⚠️ Ветвление строго по
code === 'not_found'/'conflict'/'invalid_request'больше не работает для бизнес-ошибок. Ветвитесь по HTTP-статусу (основной механизм) и по namespaced-codeтолько там, где нужна особая реакция. messageуже локализован сервером — его можно показывать пользователю как есть.
Формат ответа
Любая ошибка приходит в одном конверте:
{
"code": "projects_name_required",
"message": "Название обязательно",
"details": [
{ "field": "name", "message": "Обязательное поле" }
]
}| Поле | Тип | Что с этим делать |
|---|---|---|
code | string, стабильный | Машинная ветка. Формат <module>_<reason> для бизнес-ошибок, generic — для сквозных (см. ниже). |
message | string, локализован | Готовый текст для тоста/баннера. Не придумывайте свой по code. |
details | Array<{ field?, message }> или нет | Привязка ошибок к полям формы. Есть только у валидационных/полевых ошибок. |
TypeScript-тип:
interface ApiErrorDetail {
field?: string; // snake_case имя поля DTO, напр. "department_id"
message: string; // подсказка под полем по-русски, напр. "Обязательное поле"
}
interface ApiError {
code: string;
message: string;
details?: ApiErrorDetail[];
}Язык сообщений
Сервер локализует message по заголовку Accept-Language:
Accept-Language: en→ английский;- иначе (в т.ч.
ru, отсутствие заголовка) → русский (дефолт).
Задача фронта: ставить Accept-Language на каждом запросе в соответствии с локалью UI (один раз — в http-клиенте). Тогда message можно показывать пользователю напрямую.
Главный принцип: ветвимся по HTTP-статусу, уточняем по code
HTTP-статус — это стабильная, узкая ось (9 значений). code — широкая ось (сотни значений). Поэтому:
- Общее поведение определяйте по статусу (см. таблицу).
- Точечную реакцию (особый UX для конкретного бизнес-кейса) — по namespaced-
code. - Полевые ошибки раскладывайте из
details[].fieldпо инпутам формы.
| HTTP | Что произошло | Базовое поведение фронта |
|---|---|---|
| 400 | Валидация / некорректный ввод / статус | Показать details под полями; если details нет — тост с message. |
| 401 | Не аутентифицирован | Сбросить сессию → попытка refresh → если не вышло, редирект на логин. Не тост. |
| 403 | Нет прав / запрещённый переход | Тост/баннер «нет доступа» с message; не редиректить на логин. |
| 404 | Сущность не найдена | Экран «не найдено» для страницы сущности; тост для фонового действия. |
| 409 | Конфликт состояния | Тост с message; при необходимости — перезагрузить актуальное состояние. |
| 5xx | Внутренняя ошибка | Generic-тост «Что-то пошло не так», message не показывать как есть, залогировать. |
Сквозные (generic) коды — на них можно опираться по имени
Эти коды не зависят от модуля и стабильны — их выдаёт транспортный/мидлварный слой:
code | HTTP | Когда |
|---|---|---|
validation_error | 400 | Ошибка биндинга/валидации тела запроса. Всегда есть details. |
unauthorized | 401 | Нет/невалиден токен (мидлвара аутентификации). |
access_denied | 403 | Отказ RBAC/Casbin на уровне мидлвары. |
internal_error | 500 | Непредвиденная ошибка сервера. |
На время инкрементальной миграции в немигрированных путях ещё могут встречаться legacy-generic-коды (
not_found,conflict,invalid_request). Не закладывайтесь на них — обрабатывайте такие случаи по HTTP-статусу. Бизнес-ошибки уже приходят с namespaced-кодами.
Готовый перехватчик (axios)
Один централизованный обработчик на http-клиент. Не размазывайте try/catch по компонентам.
import axios, { AxiosError } from 'axios';
export const http = axios.create({ baseURL: '/api/v1' });
// Локаль UI → Accept-Language на каждом запросе.
http.interceptors.request.use((config) => {
config.headers.set('Accept-Language', currentLocale()); // 'ru' | 'en'
return config;
});
function parseApiError(err: AxiosError<ApiError>): ApiError {
const data = err.response?.data;
if (data && typeof data.code === 'string') return data;
// Сеть/таймаут/непарсимый ответ — синтезируем конверт.
return { code: 'network_error', message: 'Нет связи с сервером' };
}
http.interceptors.response.use(
(res) => res,
async (err: AxiosError<ApiError>) => {
const status = err.response?.status;
const apiError = parseApiError(err);
switch (status) {
case 401:
// единая точка: refresh-токена → иначе logout + редирект
await handleUnauthorized();
break;
case 403:
toast.error(apiError.message);
break;
case 404:
// обычно обрабатывается на уровне страницы, тост опционален
break;
case 409:
toast.error(apiError.message);
break;
case 400:
// полевые ошибки отдаём вызывающему коду формы (см. ниже);
// тост только если нет details
if (!apiError.details?.length) toast.error(apiError.message);
break;
default:
if (status && status >= 500) {
toast.error('Что-то пошло не так. Попробуйте позже.');
logError(apiError, err);
} else {
toast.error(apiError.message);
}
}
// пробрасываем нормализованный конверт дальше — форма решит, что с ним делать
return Promise.reject(apiError);
},
);Раскладка ошибок по полям формы
details[].field приходит в snake_case (имена полей DTO). Маппьте их на свои инпуты.
// utility: ApiError -> { [field]: message }
export function fieldErrors(err: unknown): Record<string, string> {
const e = err as ApiError;
const out: Record<string, string> = {};
for (const d of e?.details ?? []) {
if (d.field) out[d.field] = d.message;
}
return out;
}
// в submit-обработчике формы:
try {
await http.post('/projects', payload);
} catch (err) {
const errs = fieldErrors(err); // { department_id: 'Обязательное поле', ... }
if (Object.keys(errs).length) {
form.setErrors(mapSnakeToFormKeys(errs)); // подсветить инпуты
} else {
// не полевая ошибка — её уже показал перехватчик тостом
}
}
details[].message— готовая короткая подсказка под полем на русском («Обязательное поле», «Не короче 3 символов», «Неверный тип значения»). Её можно показывать под полем как есть; правила текстов — в Поля и валидация.
Точечная реакция по code
Только там, где нужен особый сценарий, а не просто тост. Ветвитесь по namespaced-коду:
catch (err) {
const e = err as ApiError;
if (e.code === 'projects_archived') {
// напр. показать кнопку «Разархивировать» вместо общего тоста
openUnarchiveDialog();
return;
}
// иначе — поведение по умолчанию (его уже отработал перехватчик)
}Полный перечень кодов — это apperr.New(...) в файлах internal/modules/<module>/errors.go. Заводите ветку по code точечно под конкретный UX; для остального достаточно статуса + message.
Задача для фронта (чеклист внедрения)
- [ ] HTTP-клиент: добавить request-интерсептор, проставляющий
Accept-Languageиз локали UI. - [ ] Тип
ApiError(code/message/details) + нормализаторparseApiError(включая сетевые ошибки без тела). - [ ] Единый response-интерсептор с веткой по HTTP-статусу (таблица выше). Никаких разрозненных
c.JSON-парсеров по компонентам. - [ ] 401: единая точка
handleUnauthorized()— refresh-токен, при неудаче logout + редирект на логин. Без тоста. - [ ] 403 / 409 / 5xx: тосты по правилам из таблицы (5xx — generic-текст, не
message). - [ ] Формы: утилита
fieldErrors()+ маппинг snake_case → ключи формы; подсветка инпутов изdetails. - [ ] Точечные
code-ветки: только под конкретные UX-сценарии (напр.*_archived,*_conflict), не по generic-имени. - [ ] Убрать legacy-ветвления вида
code === 'not_found'для бизнес-логики → перевести на HTTP-статус. - [ ] Локализация подписей полей: словарь правил/полей для
details,message— как fallback. - [ ] Логирование: 5xx и
network_errorотправлять в систему логов/мониторинга сcodeи request-id (если есть).
Чего НЕ делать
- ❌ Не показывать пользователю сырой
messageдля 5xx — это технический текст. - ❌ Не ветвиться по
code === 'not_found' | 'conflict' | 'invalid_request'в бизнес-логике — это legacy generic, на них больше нельзя закладываться. - ❌ Не строить текст ошибки самостоятельно из
code— сервер уже отдаёт локализованныйmessage. - ❌ Не обрабатывать ошибки в каждом компоненте отдельно — централизуйте в интерсепторе.