Files
router-lists-ui/frontend/src/lib/api.js
T

186 lines
7.1 KiB
JavaScript

import axios from 'axios';
import { formatDateTime } from './datetime.js';
import {
getErrorType,
isRetriableError,
getRetryDelay,
getMaxRetries,
formatErrorMessage,
getErrorDetails,
logError,
isCriticalError,
getErrorAction
} from './apiErrorHandler';
// Базовый axios-клиент для всего приложения
const api = axios.create({
baseURL: '/api',
timeout: 30000, // Увеличен до 30 секунд для больших запросов
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
validateStatus: (status) => (status >= 200 && status < 300) || status === 304,
});
// Простое кеширование ответов GET по ключу запроса + ETag
const responseCache = new Map(); // key -> { etag, data, headers }
function buildCacheKey(config) {
try {
const url = config.baseURL ? new URL(config.url, 'http://x').toString().replace('http://x','') : config.url;
const params = config.params || {};
const keys = Object.keys(params).sort();
const qs = keys.map(k => `${k}=${encodeURIComponent(params[k])}`).join('&');
return `${config.method || 'get'} ${url}${qs ? '?' + qs : ''}`;
} catch {
return `${config.method || 'get'} ${config.url}`;
}
}
// Улучшенный retry interceptor с поддержкой всех методов и типов ошибок
api.interceptors.response.use(
(response) => {
try {
const method = String(response?.config?.method || 'get').toLowerCase();
if (method === 'get') {
const key = buildCacheKey(response.config);
const etag = response.headers?.etag;
// Если 304 — всегда пробуем вернуть кеш, не трогая response.data
if (response.status === 304) {
const cached = responseCache.get(key);
if (cached) {
return { ...response, status: 200, data: cached.data, headers: { ...cached.headers, 'x-from-cache': '1' } };
}
return response;
}
// Не 304: обновляем кеш, но только если есть валидный etag
if (etag) {
responseCache.set(key, { etag, data: response.data, headers: response.headers });
}
}
} catch {}
return response;
},
async (error) => {
const config = error?.config || {};
const method = String(config.method || 'get').toUpperCase();
// Инициализируем счетчик попыток
config.__retryCount = config.__retryCount || 0;
// Определяем тип ошибки и возможность повтора
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 = 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 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: formatDateTime(new Date())
};
// Выбираем тип уведомления
const notifyType = isCriticalError(err) ? 'error' : 'warning';
window.notify.add(notifyType, fullMessage, extraDetails);
}
} catch (notifyError) {
console.error('Failed to show error notification:', notifyError);
}
return Promise.reject(err);
}
);
// Request интерсептор: If-Match из etag (если не задан явный заголовок)
api.interceptors.request.use((config) => {
try {
// If-None-Match для GET на базе кеша
const method = String(config.method || 'get').toLowerCase();
if (method === 'get') {
// Исключение: для /communities всегда запрашиваем свежие данные (без условного GET)
const rawUrl = String(config.url || '');
const pathOnly = rawUrl.split('?')[0];
const isCommunities = pathOnly === '/communities' || pathOnly.endsWith('/communities');
if (!isCommunities) {
const key = buildCacheKey(config);
const cached = responseCache.get(key);
if (cached?.etag) {
config.headers = config.headers || {};
if (!config.headers['If-None-Match']) config.headers['If-None-Match'] = String(cached.etag);
}
} else if (config.headers && config.headers['If-None-Match']) {
// На всякий случай удалим, если был выставлен где-то ещё
delete config.headers['If-None-Match'];
}
}
// Если заголовок не указан, но в теле есть etag — пробуем проставить If-Match
if (!config.headers?.['If-Match'] && config.data && typeof config.data === 'object' && config.data.etag) {
config.headers = config.headers || {};
config.headers['If-Match'] = String(config.data.etag);
}
} catch {}
return config;
});
// Утилита: опциональная распаковка стандартного формата
export function unwrapStd(res) {
const data = res?.data;
if (data && typeof data === 'object' && Array.isArray(data.items)) return data.items;
return data;
}
// Экспортируем утилиты обработки ошибок для использования в компонентах
export {
getErrorType,
formatErrorMessage,
getErrorDetails,
getErrorAction,
isCriticalError
} from './apiErrorHandler';
export default api;