Перейти к основному содержимому

Надёжность и UX

Надёжность UI определяется не отсутствием ошибок, а способностью системы корректно с ними справляться. Пользователь не должен терять свои данные, прогресс или контекст из-за технических проблем. Система должна предсказуемо восстанавливаться после сбоев.


Retry-поведение

MUST

  • Автоматический retry допускается только для безопасных операций:
    • Network/Connectivity errors
    • Временные ошибки 5xx (500, 502, 503, 504)
    • Идемпотентные GET-запросы
  • Retry не скрывает ошибку бесконечными попытками — ограничение по количеству попыток (обычно 3).
  • Пользователь информируется о попытках восстановления — индикатор загрузки или сообщение.

SHOULD

  • Использовать exponential backoff — увеличивать задержку между попытками (1s, 2s, 4s).
  • Показывать прогресс — "Попытка 2 из 3..." для долгих операций.

FORBIDDEN

  • Retry для ошибок валидации — 400 Bad Request с ошибками полей нельзя автоматически повторять.
  • Retry для 4xx ошибок — кроме специфичных случаев (429 Too Many Requests с Retry-After).
  • Бесконечный retry без уведомления пользователя — это создаёт ощущение зависания.

Пример реализации

async function fetchWithRetry<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
delayMs?: number;
backoff?: boolean;
} = {}
): Promise<T> {
const { maxAttempts = 3, delayMs = 1000, backoff = true } = options;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (rawError) {
const error = normalizeError(rawError);

// Не повторяем неретриабельные ошибки
if (!error.retriable || attempt === maxAttempts) {
throw error;
}

// Exponential backoff
const delay = backoff ? delayMs * Math.pow(2, attempt - 1) : delayMs;
await sleep(delay);
}
}

throw new Error('Unexpected retry flow');
}

✅ Хорошо

// Retry для network error при загрузке данных
const { data, error } = useSWR('/api/orders', fetcher, {
shouldRetryOnError: (error) => {
const normalized = normalizeError(error);
return normalized.retriable === true;
},
errorRetryCount: 3,
errorRetryInterval: 1000,
});

❌ Плохо

// FORBIDDEN: Retry для валидации
try {
await submitForm(data);
} catch (error) {
// Retry даже если 400 Bad Request
await sleep(1000);
await submitForm(data); // FORBIDDEN
}

Сохранение пользовательского прогресса

MUST

  • Ввод пользователя НЕ теряется из-за ошибок сети или сервера — данные остаются в форме.
  • Сброс формы при ошибке запрещён — только при явном действии пользователя или успешной отправке.
  • Состояние формы сохраняется при переходах — если это часть многошагового процесса.

Исключения

MAY

  • Сброс формы при успешной отправке — это ожидаемое поведение.
  • Сброс формы при явном действии пользователя — кнопка "Отменить", "Очистить".
  • Сброс формы если бизнес-логика требует — должно быть задокументировано и согласовано.

Локализация ошибок

MUST

  • Все пользовательские сообщения локализованы через i18n — используем ключи переводов.
  • Backend сообщения НЕ являются пользовательским текстом — они для логов и debugging.
  • Raw backend сообщения НЕ отображаются в UI — всегда маппим на локализованные ключи.
  • Ключи переводов структурированыerrors.network, errors.auth, errors.validation.email.

SHOULD

  • Поддерживать контекстные переменныеt('errors.not_found', { resource: 'Order #123' }).
  • Разные формулировки для разных контекстов — "Страница не найдена" vs "Заказ не найден".

Пример структуры i18n

{
"errors": {
"network": "Проблема с подключением. Проверьте интернет и попробуйте снова.",
"auth": "Сессия истекла. Пожалуйста, войдите заново.",
"not_found": "{{resource}} не найден.",
"conflict": "Данные были изменены. Пожалуйста, обновите страницу.",
"unknown": "Что-то пошло не так. Попробуйте позже.",

"validation": {
"required": "Поле обязательно для заполнения",
"email": "Введите корректный email",
"min_length": "Минимум {{min}} символов"
}
}
}

✅ Хорошо

const error = normalizeError(rawError);
toast.error(t(error.messageKey));

❌ Плохо

catch (error) {
// FORBIDDEN: Показываем raw backend message
toast.error(error.response.data.message);
}

Временные отклонения (MVP / Small Projects)

Для MVP и небольших проектов допускаются упрощения, но с чёткими границами.

MAY

  • Ограниченная классификация ошибок — только Network, Auth, Other (без детализации).
  • Упрощённая retry-стратегия — фиксированное количество попыток без exponential backoff.
  • Минимальный набор Error Boundaries — только глобальный, без локальных.
  • Упрощённая локализация — общие сообщения вместо контекстных.

MUST (даже для MVP)

  • Raw backend messages НЕ отображаются — хотя бы базовый маппинг на пользовательские сообщения.
  • Ошибки НЕ приводят к крашу приложения — минимальный Error Boundary обязателен.
  • Пользовательский ввод НЕ теряется — формы не сбрасываются при ошибках.
  • Уровни отображения ошибок логически корректны — валидация inline, критичные ошибки глобально.

Когда возвращаться к полным правилам

Упрощения перестают быть приемлемыми когда:

  • Рост количества пользовательских сценариев — становится сложно поддерживать без классификации.
  • Появление сложных форм и состояний — многошаговые формы, драфты, автосохранение.
  • Рост команды — нужна унификация подходов.
  • Необходимость унификации UX — пользователи замечают непоследовательность.
  • Переход от прототипа к продукту — техдолг начинает замедлять развитие.

Пример MVP подхода

// Упрощённая нормализация для MVP
function normalizeErrorMVP(error: unknown): {
message: string;
retriable: boolean;
} {
if (isNetworkError(error)) {
return { message: t('errors.network'), retriable: true };
}

if (isAuthError(error)) {
return { message: t('errors.auth'), retriable: false };
}

// Всё остальное - general error
return { message: t('errors.general'), retriable: false };
}