feat: Добавление компонентов ErrorBoundary и NetworkErrorHandler для улучшения обработки ошибок в приложении. Увеличение таймаута запросов до 30 секунд и улучшение логики повторных попыток с детализированными уведомлениями об ошибках.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
This commit is contained in:
+18
-13
@@ -35,6 +35,8 @@ import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||
import SettingsModal from './components/SettingsModal.jsx';
|
||||
import ToastContainer from './components/ToastContainer.jsx';
|
||||
import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPalette.jsx';
|
||||
import ErrorBoundary from './components/ErrorBoundary.jsx';
|
||||
import NetworkErrorHandler from './components/NetworkErrorHandler.jsx';
|
||||
|
||||
// --- Simple i18n (RU/EN) ---
|
||||
const LanguageContext = createContext({ lang: 'ru', setLang: () => {}, t: (k) => k });
|
||||
@@ -91,19 +93,22 @@ const queryClient = new QueryClient({
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<ToastContainer>
|
||||
<NotifyProvider>
|
||||
<MainLayout />
|
||||
</NotifyProvider>
|
||||
</ToastContainer>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
</Router>
|
||||
<ErrorBoundary>
|
||||
<Router>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<ToastContainer>
|
||||
<NotifyProvider>
|
||||
<NetworkErrorHandler />
|
||||
<MainLayout />
|
||||
</NotifyProvider>
|
||||
</ToastContainer>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Component } from 'react';
|
||||
import { IconAlertTriangle, IconRefresh, IconHome } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* ErrorBoundary - глобальный обработчик ошибок React
|
||||
* Ловит ошибки рендеринга и показывает fallback UI
|
||||
*/
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
errorCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Логируем ошибку
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// Отправляем в мониторинг (если настроен)
|
||||
this.logErrorToService(error, errorInfo);
|
||||
|
||||
this.setState(prevState => ({
|
||||
error,
|
||||
errorInfo,
|
||||
errorCount: prevState.errorCount + 1
|
||||
}));
|
||||
}
|
||||
|
||||
logErrorToService(error, errorInfo) {
|
||||
// Можно интегрировать с Sentry, LogRocket и т.д.
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.notify?.error) {
|
||||
window.notify.error('Произошла критическая ошибка приложения', {
|
||||
error: error.toString(),
|
||||
componentStack: errorInfo?.componentStack
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to log error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
});
|
||||
|
||||
// Очищаем localStorage если ошибка повторяется
|
||||
if (this.state.errorCount > 2) {
|
||||
try {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
} catch (e) {
|
||||
console.error('Failed to clear storage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Перезагружаем страницу если много ошибок
|
||||
if (this.state.errorCount > 3) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="page page-center">
|
||||
<div className="container-tight py-4">
|
||||
<div className="empty">
|
||||
<div className="empty-icon">
|
||||
<IconAlertTriangle size={64} className="text-danger" />
|
||||
</div>
|
||||
<p className="empty-title">Произошла ошибка</p>
|
||||
<p className="empty-subtitle text-muted">
|
||||
{this.state.error?.message || 'Что-то пошло не так. Попробуйте обновить страницу.'}
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
|
||||
<div className="card mt-3">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Детали ошибки (только в dev режиме)</h3>
|
||||
<pre className="text-start" style={{
|
||||
fontSize: '0.75rem',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{this.state.error?.toString()}
|
||||
{'\n\n'}
|
||||
{this.state.errorInfo?.componentStack}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="empty-action">
|
||||
<div className="btn-list justify-content-center">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={this.handleReset}
|
||||
>
|
||||
<IconRefresh className="icon" />
|
||||
Попробовать снова
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={this.handleReload}
|
||||
>
|
||||
Перезагрузить страницу
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={this.handleGoHome}
|
||||
>
|
||||
<IconHome className="icon" />
|
||||
На главную
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.state.errorCount > 1 && (
|
||||
<div className="alert alert-warning mt-3">
|
||||
<p className="mb-0">
|
||||
Ошибка повторяется ({this.state.errorCount} раз).
|
||||
{this.state.errorCount > 2 && ' При следующей попытке кэш будет очищен.'}
|
||||
{this.state.errorCount > 3 && ' Следующая попытка приведёт к полной перезагрузке.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { IconWifi, IconWifiOff } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* NetworkErrorHandler - компонент для мониторинга состояния сети
|
||||
* Показывает уведомление при потере соединения
|
||||
*/
|
||||
function NetworkErrorHandler() {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
const [wasOffline, setWasOffline] = useState(false);
|
||||
const [showReconnected, setShowReconnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setIsOnline(true);
|
||||
if (wasOffline) {
|
||||
setShowReconnected(true);
|
||||
// Показываем уведомление о восстановлении на 3 секунды
|
||||
setTimeout(() => {
|
||||
setShowReconnected(false);
|
||||
setWasOffline(false);
|
||||
}, 3000);
|
||||
|
||||
// Уведомляем через глобальную систему
|
||||
if (window.notify?.success) {
|
||||
window.notify.success('Соединение восстановлено');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
setIsOnline(false);
|
||||
setWasOffline(true);
|
||||
|
||||
// Уведомляем через глобальную систему
|
||||
if (window.notify?.warning) {
|
||||
window.notify.warning('Нет соединения с интернетом');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [wasOffline]);
|
||||
|
||||
// Не показываем ничего если онлайн и не было офлайна
|
||||
if (isOnline && !showReconnected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="position-fixed top-0 start-0 end-0"
|
||||
style={{ zIndex: 1090 }}
|
||||
>
|
||||
{!isOnline ? (
|
||||
<div className="alert alert-danger mb-0 rounded-0 border-0" role="alert">
|
||||
<div className="d-flex align-items-center justify-content-center">
|
||||
<IconWifiOff className="icon me-2" />
|
||||
<strong>Нет соединения с интернетом</strong>
|
||||
<span className="ms-2 text-muted">Ожидание восстановления...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : showReconnected ? (
|
||||
<div className="alert alert-success mb-0 rounded-0 border-0" role="alert">
|
||||
<div className="d-flex align-items-center justify-content-center">
|
||||
<IconWifi className="icon me-2" />
|
||||
<strong>Соединение восстановлено</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NetworkErrorHandler;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { IconRefresh } from '@tabler/icons-react';
|
||||
|
||||
/**
|
||||
* RetryButton - кнопка для повторной попытки с индикацией загрузки
|
||||
*/
|
||||
function RetryButton({
|
||||
onRetry,
|
||||
loading: externalLoading,
|
||||
disabled,
|
||||
className = 'btn btn-primary',
|
||||
children = 'Повторить',
|
||||
showIcon = true,
|
||||
...props
|
||||
}) {
|
||||
const [internalLoading, setInternalLoading] = useState(false);
|
||||
const loading = externalLoading !== undefined ? externalLoading : internalLoading;
|
||||
|
||||
const handleClick = async () => {
|
||||
if (loading || disabled) return;
|
||||
|
||||
try {
|
||||
setInternalLoading(true);
|
||||
await onRetry();
|
||||
} finally {
|
||||
setInternalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${className}${loading ? ' btn-loading' : ''}`}
|
||||
disabled={disabled || loading}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && !loading && <IconRefresh className="icon" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default RetryButton;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Примеры использования новой системы обработки ошибок
|
||||
* Этот файл демонстрирует различные сценарии использования
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useErrorHandler } from '../hooks/useErrorHandler';
|
||||
import RetryButton from '../components/RetryButton';
|
||||
import api from '../lib/api';
|
||||
|
||||
/**
|
||||
* Пример 1: Простая обработка ошибок в компоненте
|
||||
*/
|
||||
function SimpleErrorHandlingExample() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { handleError, handleSuccess, withErrorHandler } = useErrorHandler();
|
||||
|
||||
// Вариант 1: Ручная обработка ошибок
|
||||
const fetchDataManual = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/domains-new');
|
||||
setData(response.data);
|
||||
handleSuccess('Данные успешно загружены');
|
||||
} catch (error) {
|
||||
handleError(error, { component: 'SimpleErrorHandlingExample' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Вариант 2: Автоматическая обработка с withErrorHandler
|
||||
const fetchDataAuto = withErrorHandler(
|
||||
async () => {
|
||||
setLoading(true);
|
||||
const response = await api.get('/domains-new');
|
||||
setData(response.data);
|
||||
setLoading(false);
|
||||
},
|
||||
{
|
||||
successMessage: 'Данные успешно загружены',
|
||||
context: { component: 'SimpleErrorHandlingExample' }
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Пример простой обработки ошибок</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={fetchDataManual}
|
||||
disabled={loading}
|
||||
>
|
||||
Загрузить (ручная обработка)
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={fetchDataAuto}
|
||||
disabled={loading}
|
||||
>
|
||||
Загрузить (автоматическая обработка)
|
||||
</button>
|
||||
</div>
|
||||
{data && (
|
||||
<div className="mt-3">
|
||||
<p>Загружено записей: {data.items?.length || 0}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Пример 2: Использование RetryButton
|
||||
*/
|
||||
function RetryButtonExample() {
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
const { handleError, handleSuccess } = useErrorHandler();
|
||||
|
||||
const unreliableOperation = async () => {
|
||||
setAttempts(prev => prev + 1);
|
||||
|
||||
// Симуляция операции которая иногда падает
|
||||
if (Math.random() < 0.5) {
|
||||
throw new Error('Случайная ошибка для демонстрации');
|
||||
}
|
||||
|
||||
handleSuccess('Операция выполнена успешно!');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Пример использования RetryButton</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p>Попыток: {attempts}</p>
|
||||
<RetryButton
|
||||
onRetry={unreliableOperation}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Выполнить ненадежную операцию
|
||||
</RetryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Пример 3: Обработка различных типов ошибок
|
||||
*/
|
||||
function ErrorTypesExample() {
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
const triggerNetworkError = () => {
|
||||
api.get('/nonexistent-endpoint')
|
||||
.catch(error => handleError(error));
|
||||
};
|
||||
|
||||
const triggerValidationError = () => {
|
||||
api.post('/domains-new', { invalid: 'data' })
|
||||
.catch(error => handleError(error));
|
||||
};
|
||||
|
||||
const triggerTimeoutError = () => {
|
||||
// Создаем запрос который точно превысит таймаут
|
||||
const slowApi = api.create({ timeout: 100 });
|
||||
slowApi.get('/domains-new')
|
||||
.catch(error => handleError(error));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Типы ошибок</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={triggerNetworkError}
|
||||
>
|
||||
Сетевая ошибка
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
onClick={triggerValidationError}
|
||||
>
|
||||
Ошибка валидации
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-info"
|
||||
onClick={triggerTimeoutError}
|
||||
>
|
||||
Таймаут
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Главный компонент с примерами
|
||||
*/
|
||||
function ErrorHandlingExample() {
|
||||
return (
|
||||
<div className="container-xl">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Примеры обработки ошибок</h1>
|
||||
<p className="text-muted">
|
||||
Демонстрация возможностей новой системы обработки ошибок
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="row row-cards">
|
||||
<div className="col-12">
|
||||
<SimpleErrorHandlingExample />
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<RetryButtonExample />
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<ErrorTypesExample />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mt-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Особенности новой системы</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ul>
|
||||
<li>✅ <strong>Автоматический retry</strong> для GET запросов (до 3 попыток)</li>
|
||||
<li>✅ <strong>Умный retry</strong> для POST/PUT/DELETE при сетевых ошибках</li>
|
||||
<li>✅ <strong>Exponential backoff</strong> с jitter для избежания thundering herd</li>
|
||||
<li>✅ <strong>User-friendly сообщения</strong> вместо технических ошибок</li>
|
||||
<li>✅ <strong>Детальная информация</strong> для отладки (requestId, timestamp, детали)</li>
|
||||
<li>✅ <strong>Offline/Online detection</strong> с уведомлениями</li>
|
||||
<li>✅ <strong>Error Boundary</strong> для ловли React ошибок рендеринга</li>
|
||||
<li>✅ <strong>Типизация ошибок</strong> (Network, Timeout, Server, Client, etc.)</li>
|
||||
<li>✅ <strong>Интеграция с системой уведомлений</strong></li>
|
||||
<li>✅ <strong>Логирование критичных ошибок</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorHandlingExample;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useNotify } from '../components/NotifyProvider';
|
||||
import { formatErrorMessage, getErrorAction, getErrorDetails } from '../lib/api';
|
||||
|
||||
/**
|
||||
* useErrorHandler - хук для обработки ошибок в компонентах
|
||||
* Предоставляет единообразный способ обработки ошибок
|
||||
*/
|
||||
export function useErrorHandler() {
|
||||
const notify = useNotify();
|
||||
|
||||
const handleError = useCallback((error, context = {}) => {
|
||||
console.error('Error in component:', error, context);
|
||||
|
||||
const message = formatErrorMessage(error);
|
||||
const action = getErrorAction(error);
|
||||
const details = getErrorDetails(error);
|
||||
|
||||
notify.error(message, {
|
||||
...details,
|
||||
action,
|
||||
context
|
||||
});
|
||||
}, [notify]);
|
||||
|
||||
const handleSuccess = useCallback((message = 'Операция выполнена успешно') => {
|
||||
notify.success(message);
|
||||
}, [notify]);
|
||||
|
||||
const handleWarning = useCallback((message, details) => {
|
||||
notify.warning(message, details);
|
||||
}, [notify]);
|
||||
|
||||
const handleInfo = useCallback((message, details) => {
|
||||
notify.info(message, details);
|
||||
}, [notify]);
|
||||
|
||||
// Обёртка для async функций с автоматической обработкой ошибок
|
||||
const withErrorHandler = useCallback((asyncFn, options = {}) => {
|
||||
return async (...args) => {
|
||||
try {
|
||||
const result = await asyncFn(...args);
|
||||
|
||||
if (options.successMessage) {
|
||||
handleSuccess(options.successMessage);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!options.silent) {
|
||||
handleError(error, options.context);
|
||||
}
|
||||
|
||||
if (options.rethrow) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return options.defaultValue;
|
||||
}
|
||||
};
|
||||
}, [handleError, handleSuccess]);
|
||||
|
||||
return {
|
||||
handleError,
|
||||
handleSuccess,
|
||||
handleWarning,
|
||||
handleInfo,
|
||||
withErrorHandler
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useAsyncError - хук для обработки async ошибок в useEffect
|
||||
*/
|
||||
export function useAsyncError() {
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
return useCallback((promise, context) => {
|
||||
promise.catch(error => handleError(error, context));
|
||||
}, [handleError]);
|
||||
}
|
||||
|
||||
+74
-20
@@ -1,9 +1,20 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
getErrorType,
|
||||
isRetriableError,
|
||||
getRetryDelay,
|
||||
getMaxRetries,
|
||||
formatErrorMessage,
|
||||
getErrorDetails,
|
||||
logError,
|
||||
isCriticalError,
|
||||
getErrorAction
|
||||
} from './apiErrorHandler';
|
||||
|
||||
// Базовый axios-клиент для всего приложения
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
timeout: 30000, // Увеличен до 30 секунд для больших запросов
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
@@ -24,7 +35,7 @@ function buildCacheKey(config) {
|
||||
}
|
||||
}
|
||||
|
||||
// Авто-ретрай для идемпотентных GET: до 2 попыток с экспоненциальной задержкой
|
||||
// Улучшенный retry interceptor с поддержкой всех методов и типов ошибок
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
try {
|
||||
@@ -38,9 +49,6 @@ api.interceptors.response.use(
|
||||
if (cached) {
|
||||
return { ...response, status: 200, data: cached.data, headers: { ...cached.headers, 'x-from-cache': '1' } };
|
||||
}
|
||||
// нет кеша — вернём пустые семантически корректные данные (чтобы не падали .map)
|
||||
// вызывающий код должен ожидать типы, поэтому лучше не подменять тип неожиданно.
|
||||
// Просто пропустим дальше как есть — до второго интерсептора и обработчиков.
|
||||
return response;
|
||||
}
|
||||
// Не 304: обновляем кеш, но только если есть валидный etag
|
||||
@@ -53,35 +61,72 @@ api.interceptors.response.use(
|
||||
},
|
||||
async (error) => {
|
||||
const config = error?.config || {};
|
||||
const isGet = String(config.method || 'get').toLowerCase() === 'get';
|
||||
const status = error?.response?.status;
|
||||
const retriable = !error.response || (status >= 500 && status !== 501);
|
||||
const method = String(config.method || 'get').toUpperCase();
|
||||
|
||||
// Инициализируем счетчик попыток
|
||||
config.__retryCount = config.__retryCount || 0;
|
||||
if (isGet && retriable && config.__retryCount < 2) {
|
||||
|
||||
// Определяем тип ошибки и возможность повтора
|
||||
const errorType = getErrorType(error);
|
||||
const canRetry = isRetriableError(error, method);
|
||||
const maxRetries = getMaxRetries(errorType, method);
|
||||
|
||||
// Логируем ошибку если это критичная ошибка или последняя попытка
|
||||
if (isCriticalError(error) || config.__retryCount >= maxRetries) {
|
||||
logError(error, {
|
||||
retryAttempt: config.__retryCount,
|
||||
maxRetries,
|
||||
errorType,
|
||||
canRetry
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяем возможность повтора
|
||||
if (canRetry && config.__retryCount < maxRetries) {
|
||||
config.__retryCount += 1;
|
||||
const delay = 300 * Math.pow(2, config.__retryCount - 1);
|
||||
const delay = getRetryDelay(config.__retryCount - 1);
|
||||
|
||||
// Уведомляем о повторной попытке (только для пользовательских действий)
|
||||
if (config.__retryCount === 1 && method !== 'GET' && typeof window !== 'undefined') {
|
||||
console.log(`Повторная попытка ${config.__retryCount}/${maxRetries} для ${method} ${config.url}`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return api(config);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Нормализация ошибок и уведомления по умолчанию
|
||||
// Улучшенная нормализация ошибок с user-friendly сообщениями
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
try {
|
||||
const status = err?.response?.status;
|
||||
const data = err?.response?.data || {};
|
||||
const message = data?.message || err?.message || 'Ошибка запроса';
|
||||
const code = data?.code;
|
||||
const details = data?.details;
|
||||
const requestId = data?.requestId || err?.response?.headers?.['x-request-id'] || err?.config?.headers?.['X-Request-Id'];
|
||||
if (status >= 400 && typeof window !== 'undefined' && window.notify?.error) {
|
||||
window.notify.add('error', `${message}${status ? ` (${status})` : ''}${requestId ? ` • reqId=${requestId}` : ''}`, details ? { code, requestId, details } : undefined);
|
||||
// Не показываем уведомления если это повторная попытка
|
||||
const isRetrying = err?.config?.__retryCount > 0;
|
||||
|
||||
if (!isRetrying && typeof window !== 'undefined' && window.notify) {
|
||||
const errorDetails = getErrorDetails(err);
|
||||
const userMessage = formatErrorMessage(err);
|
||||
const actionMessage = getErrorAction(err);
|
||||
|
||||
// Формируем детальное сообщение
|
||||
const fullMessage = `${userMessage}${errorDetails.status ? ` (${errorDetails.status})` : ''}`;
|
||||
const extraDetails = {
|
||||
...errorDetails,
|
||||
action: actionMessage,
|
||||
timestamp: new Date().toLocaleString('ru-RU')
|
||||
};
|
||||
|
||||
// Выбираем тип уведомления
|
||||
const notifyType = isCriticalError(err) ? 'error' : 'warning';
|
||||
window.notify.add(notifyType, fullMessage, extraDetails);
|
||||
}
|
||||
} catch {}
|
||||
} catch (notifyError) {
|
||||
console.error('Failed to show error notification:', notifyError);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
@@ -125,6 +170,15 @@ export function unwrapStd(res) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Экспортируем утилиты обработки ошибок для использования в компонентах
|
||||
export {
|
||||
getErrorType,
|
||||
formatErrorMessage,
|
||||
getErrorDetails,
|
||||
getErrorAction,
|
||||
isCriticalError
|
||||
} from './apiErrorHandler';
|
||||
|
||||
export default api;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* apiErrorHandler - утилиты для обработки ошибок API
|
||||
*/
|
||||
|
||||
/**
|
||||
* Типы ошибок API
|
||||
*/
|
||||
export const ErrorType = {
|
||||
NETWORK: 'NETWORK', // Проблемы с сетью
|
||||
TIMEOUT: 'TIMEOUT', // Таймаут запроса
|
||||
SERVER: 'SERVER', // Ошибка сервера (5xx)
|
||||
CLIENT: 'CLIENT', // Ошибка клиента (4xx)
|
||||
VALIDATION: 'VALIDATION', // Ошибка валидации
|
||||
AUTH: 'AUTH', // Ошибка аутентификации
|
||||
PERMISSION: 'PERMISSION', // Нет прав доступа
|
||||
NOT_FOUND: 'NOT_FOUND', // Ресурс не найден
|
||||
CONFLICT: 'CONFLICT', // Конфликт данных
|
||||
RATE_LIMIT: 'RATE_LIMIT', // Превышен лимит запросов
|
||||
UNKNOWN: 'UNKNOWN' // Неизвестная ошибка
|
||||
};
|
||||
|
||||
/**
|
||||
* Определяет тип ошибки по axios error
|
||||
*/
|
||||
export function getErrorType(error) {
|
||||
if (!error) return ErrorType.UNKNOWN;
|
||||
|
||||
// Проверяем таймаут
|
||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||
return ErrorType.TIMEOUT;
|
||||
}
|
||||
|
||||
// Проверяем сетевые ошибки
|
||||
if (!error.response) {
|
||||
return ErrorType.NETWORK;
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
|
||||
// Классифицируем по статус-коду
|
||||
switch (status) {
|
||||
case 400:
|
||||
return ErrorType.VALIDATION;
|
||||
case 401:
|
||||
return ErrorType.AUTH;
|
||||
case 403:
|
||||
return ErrorType.PERMISSION;
|
||||
case 404:
|
||||
return ErrorType.NOT_FOUND;
|
||||
case 409:
|
||||
return ErrorType.CONFLICT;
|
||||
case 429:
|
||||
return ErrorType.RATE_LIMIT;
|
||||
default:
|
||||
if (status >= 400 && status < 500) {
|
||||
return ErrorType.CLIENT;
|
||||
}
|
||||
if (status >= 500) {
|
||||
return ErrorType.SERVER;
|
||||
}
|
||||
return ErrorType.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет, можно ли повторить запрос после этой ошибки
|
||||
*/
|
||||
export function isRetriableError(error, method = 'GET') {
|
||||
const errorType = getErrorType(error);
|
||||
const safeMethod = method.toUpperCase();
|
||||
|
||||
// GET запросы - можно повторять почти всегда
|
||||
if (safeMethod === 'GET') {
|
||||
return [
|
||||
ErrorType.NETWORK,
|
||||
ErrorType.TIMEOUT,
|
||||
ErrorType.SERVER,
|
||||
].includes(errorType);
|
||||
}
|
||||
|
||||
// POST/PUT/PATCH/DELETE - только сетевые и таймауты
|
||||
// Не повторяем 5xx чтобы не создать дубликаты
|
||||
return [
|
||||
ErrorType.NETWORK,
|
||||
ErrorType.TIMEOUT,
|
||||
].includes(errorType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает задержку для retry с exponential backoff
|
||||
*/
|
||||
export function getRetryDelay(attemptNumber, baseDelay = 300) {
|
||||
// Exponential backoff: 300ms, 600ms, 1200ms, 2400ms, 4800ms
|
||||
const delay = baseDelay * Math.pow(2, attemptNumber);
|
||||
// Добавляем jitter ±20% для избежания thundering herd
|
||||
const jitter = delay * 0.2 * (Math.random() - 0.5);
|
||||
return Math.min(delay + jitter, 10000); // Максимум 10 секунд
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает максимальное количество попыток для типа ошибки
|
||||
*/
|
||||
export function getMaxRetries(errorType, method = 'GET') {
|
||||
const safeMethod = method.toUpperCase();
|
||||
|
||||
// Идемпотентные методы - больше попыток
|
||||
if (safeMethod === 'GET' || safeMethod === 'HEAD') {
|
||||
switch (errorType) {
|
||||
case ErrorType.NETWORK:
|
||||
return 3;
|
||||
case ErrorType.TIMEOUT:
|
||||
return 2;
|
||||
case ErrorType.SERVER:
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Неидемпотентные методы - меньше попыток
|
||||
switch (errorType) {
|
||||
case ErrorType.NETWORK:
|
||||
return 1;
|
||||
case ErrorType.TIMEOUT:
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует сообщение об ошибке для пользователя
|
||||
*/
|
||||
export function formatErrorMessage(error) {
|
||||
const errorType = getErrorType(error);
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
|
||||
// Используем сообщение с сервера если есть
|
||||
if (data?.message) {
|
||||
return data.message;
|
||||
}
|
||||
|
||||
// Иначе генерируем понятное сообщение
|
||||
switch (errorType) {
|
||||
case ErrorType.NETWORK:
|
||||
return 'Не удалось подключиться к серверу. Проверьте соединение с интернетом.';
|
||||
case ErrorType.TIMEOUT:
|
||||
return 'Сервер не отвечает. Попробуйте повторить запрос позже.';
|
||||
case ErrorType.AUTH:
|
||||
return 'Требуется авторизация. Пожалуйста, войдите в систему.';
|
||||
case ErrorType.PERMISSION:
|
||||
return 'У вас нет прав для выполнения этой операции.';
|
||||
case ErrorType.NOT_FOUND:
|
||||
return 'Запрашиваемый ресурс не найден.';
|
||||
case ErrorType.VALIDATION:
|
||||
return 'Ошибка валидации данных. Проверьте введённые значения.';
|
||||
case ErrorType.CONFLICT:
|
||||
return 'Конфликт данных. Возможно, ресурс был изменён другим пользователем.';
|
||||
case ErrorType.RATE_LIMIT:
|
||||
return 'Превышен лимит запросов. Пожалуйста, подождите немного.';
|
||||
case ErrorType.SERVER:
|
||||
return `Ошибка сервера (${status}). Пожалуйста, попробуйте позже.`;
|
||||
case ErrorType.CLIENT:
|
||||
return `Ошибка запроса (${status}). ${error.message}`;
|
||||
default:
|
||||
return error.message || 'Произошла неизвестная ошибка.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает детали ошибки для отладки
|
||||
*/
|
||||
export function getErrorDetails(error) {
|
||||
const data = error.response?.data;
|
||||
|
||||
return {
|
||||
type: getErrorType(error),
|
||||
status: error.response?.status,
|
||||
code: data?.code || error.code,
|
||||
message: formatErrorMessage(error),
|
||||
requestId: data?.requestId || error.response?.headers?.['x-request-id'],
|
||||
details: data?.details,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method?.toUpperCase(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли ошибка критичной (требует немедленного внимания)
|
||||
*/
|
||||
export function isCriticalError(error) {
|
||||
const errorType = getErrorType(error);
|
||||
return [
|
||||
ErrorType.SERVER,
|
||||
ErrorType.AUTH,
|
||||
].includes(errorType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Логирует ошибку (для отправки в систему мониторинга)
|
||||
*/
|
||||
export function logError(error, context = {}) {
|
||||
const details = getErrorDetails(error);
|
||||
|
||||
console.error('API Error:', {
|
||||
...details,
|
||||
...context,
|
||||
stackTrace: error.stack
|
||||
});
|
||||
|
||||
// Здесь можно добавить отправку в Sentry, LogRocket и т.д.
|
||||
// if (window.Sentry) {
|
||||
// window.Sentry.captureException(error, {
|
||||
// contexts: { api: details, custom: context }
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает user-friendly сообщение с рекомендациями по устранению
|
||||
*/
|
||||
export function getErrorAction(error) {
|
||||
const errorType = getErrorType(error);
|
||||
|
||||
switch (errorType) {
|
||||
case ErrorType.NETWORK:
|
||||
return 'Проверьте подключение к интернету и повторите попытку.';
|
||||
case ErrorType.TIMEOUT:
|
||||
return 'Сервер долго отвечает. Попробуйте обновить страницу.';
|
||||
case ErrorType.AUTH:
|
||||
return 'Пожалуйста, войдите в систему снова.';
|
||||
case ErrorType.PERMISSION:
|
||||
return 'Обратитесь к администратору для получения доступа.';
|
||||
case ErrorType.VALIDATION:
|
||||
return 'Проверьте правильность введённых данных.';
|
||||
case ErrorType.CONFLICT:
|
||||
return 'Обновите страницу и попробуйте снова.';
|
||||
case ErrorType.RATE_LIMIT:
|
||||
return 'Подождите несколько секунд и повторите попытку.';
|
||||
case ErrorType.SERVER:
|
||||
return 'Попробуйте повторить операцию через несколько минут.';
|
||||
default:
|
||||
return 'Попробуйте обновить страницу или обратитесь в поддержку.';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user