Надёжность и 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 };
}