Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m41s
131 lines
5.7 KiB
JavaScript
131 lines
5.7 KiB
JavaScript
import axios from 'axios';
|
|
|
|
// Базовый axios-клиент для всего приложения
|
|
const api = axios.create({
|
|
baseURL: '/api',
|
|
timeout: 10000,
|
|
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}`;
|
|
}
|
|
}
|
|
|
|
// Авто-ретрай для идемпотентных GET: до 2 попыток с экспоненциальной задержкой
|
|
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' } };
|
|
}
|
|
// нет кеша — вернём пустые семантически корректные данные (чтобы не падали .map)
|
|
// вызывающий код должен ожидать типы, поэтому лучше не подменять тип неожиданно.
|
|
// Просто пропустим дальше как есть — до второго интерсептора и обработчиков.
|
|
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 isGet = String(config.method || 'get').toLowerCase() === 'get';
|
|
const status = error?.response?.status;
|
|
const retriable = !error.response || (status >= 500 && status !== 501);
|
|
config.__retryCount = config.__retryCount || 0;
|
|
if (isGet && retriable && config.__retryCount < 2) {
|
|
config.__retryCount += 1;
|
|
const delay = 300 * Math.pow(2, config.__retryCount - 1);
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
return api(config);
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// Нормализация ошибок и уведомления по умолчанию
|
|
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);
|
|
}
|
|
} catch {}
|
|
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 default api;
|
|
|
|
|