Skip to content

Обработка ошибок на фронте ​

Инструкция для фронтенда: как читать ошибки 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 уже локализован сервером — его можно показывать пользователю как есть.

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

Любая ошибка приходит в одном конверте:

json
{
  "code": "projects_name_required",
  "message": "Название обязательно",
  "details": [
    { "field": "name", "message": "Обязательное поле" }
  ]
}
ПолеТипЧто с этим делать
codestring, стабильныйМашинная ветка. Формат <module>_<reason> для бизнес-ошибок, generic — для сквозных (см. ниже).
messagestring, локализованГотовый текст для тоста/баннера. Не придумывайте свой по code.
detailsArray<{ field?, message }> или нетПривязка ошибок к полям формы. Есть только у валидационных/полевых ошибок.

TypeScript-тип:

ts
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 — широкая ось (сотни значений). Поэтому:

  1. Общее поведение определяйте по статусу (см. таблицу).
  2. Точечную реакцию (особый UX для конкретного бизнес-кейса) — по namespaced-code.
  3. Полевые ошибки раскладывайте из details[].field по инпутам формы.
HTTPЧто произошлоБазовое поведение фронта
400Валидация / некорректный ввод / статусПоказать details под полями; если details нет — тост с message.
401Не аутентифицированСбросить сессию → попытка refresh → если не вышло, редирект на логин. Не тост.
403Нет прав / запрещённый переходТост/баннер «нет доступа» с message; не редиректить на логин.
404Сущность не найденаЭкран «не найдено» для страницы сущности; тост для фонового действия.
409Конфликт состоянияТост с message; при необходимости — перезагрузить актуальное состояние.
5xxВнутренняя ошибкаGeneric-тост «Что-то пошло не так», message не показывать как есть, залогировать.

Сквозные (generic) коды — на них можно опираться по имени ​

Эти коды не зависят от модуля и стабильны — их выдаёт транспортный/мидлварный слой:

codeHTTPКогда
validation_error400Ошибка биндинга/валидации тела запроса. Всегда есть details.
unauthorized401Нет/невалиден токен (мидлвара аутентификации).
access_denied403Отказ RBAC/Casbin на уровне мидлвары.
internal_error500Непредвиденная ошибка сервера.

На время инкрементальной миграции в немигрированных путях ещё могут встречаться legacy-generic-коды (not_found, conflict, invalid_request). Не закладывайтесь на них — обрабатывайте такие случаи по HTTP-статусу. Бизнес-ошибки уже приходят с namespaced-кодами.


Готовый перехватчик (axios) ​

Один централизованный обработчик на http-клиент. Не размазывайте try/catch по компонентам.

ts
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). Маппьте их на свои инпуты.

ts
// 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-коду:

ts
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.
  • ❌ Не обрабатывать ошибки в каждом компоненте отдельно — централизуйте в интерсепторе.

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