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; if (etag) { responseCache.set(key, { etag, data: response.data, headers: response.headers }); } // Если пришёл 304 (на всякий случай, axios обычно не кидает в success 304), подменим данными из кеша 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' } }; } } } } 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') { 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); } } // Если заголовок не указан, но в теле есть 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;