Перейти к содержанию

Конфигурация Axios — Полный код

Уровень: L3 (deep-dive) | ← Назад к API Layer

Файл: frontend/src/api/client.ts

import axios from 'axios';
import { useAuthStore } from '../stores/auth';

// Базовый URL из переменной окружения, fallback на /api для проксирования через Vite
const baseURL = import.meta.env.VITE_API_URL || '/api';

// Создание единственного экземпляра axios для всего приложения
export const apiClient = axios.create({
  baseURL,
  headers: { 'Content-Type': 'application/json' },
});

// ─── REQUEST INTERCEPTOR ─────────────────────────────────────────────────────
// Автоматически добавляет Bearer-токен к каждому запросу
apiClient.interceptors.request.use((config) => {
  const { accessToken } = useAuthStore.getState();
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

// ─── RESPONSE INTERCEPTOR — AUTO REFRESH ─────────────────────────────────────

// Флаг: идёт ли сейчас процесс обновления токена
let isRefreshing = false;

// Очередь запросов, ожидающих завершения refresh
let failedQueue: Array<{
  resolve: (value: unknown) => void;
  reject: (reason: unknown) => void;
}> = [];

// Обработка очереди после завершения refresh (успех или ошибка)
function processQueue(error: unknown) {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error);  // Refresh не удался — отклонить все ожидающие запросы
    } else {
      resolve(undefined);  // Refresh успешен — повторить запросы
    }
  });
  failedQueue = [];
}

apiClient.interceptors.response.use(
  (response) => response,  // Успешные ответы проходят без изменений
  async (error) => {
    const originalRequest = error.config;

    // Пропустить, если не 401 или запрос уже повторялся
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error);
    }

    // Пропустить auth-эндпоинты (избежать бесконечного цикла)
    if (originalRequest.url?.includes('/auth/refresh') || originalRequest.url?.includes('/auth/login')) {
      return Promise.reject(error);
    }

    // Если refresh уже идёт — поставить запрос в очередь
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      }).then(() => apiClient(originalRequest));  // После refresh — повторить запрос
    }

    // Пометить запрос как повторяемый (защита от рекурсии)
    originalRequest._retry = true;
    isRefreshing = true;

    const { refreshToken, setTokens, logout } = useAuthStore.getState();

    // Нет refresh token — сразу logout
    if (!refreshToken) {
      await logout();
      isRefreshing = false;
      return Promise.reject(error);
    }

    try {
      // Запрос нового access token
      const { data } = await axios.post(`${baseURL}/auth/refresh`, {
        refresh_token: refreshToken,
      });

      // Сохранить новые токены в store
      setTokens(data.access_token, data.refresh_token);
      // Обработать очередь ожидающих запросов (без ошибки = повторить)
      processQueue(null);
      // Повторить оригинальный запрос с новым токеном
      return apiClient(originalRequest);
    } catch (refreshError) {
      // Refresh не удался — отклонить всю очередь и выйти
      processQueue(refreshError);
      await logout();
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  },
);

Разбор логики refresh

Проблема

При истечении access token (обычно 15-30 мин) любой API-запрос вернёт 401. Если в этот момент выполняется несколько параллельных запросов — все получат 401 одновременно.

Решение: Queue-based refresh

  1. Первый 401 → устанавливает isRefreshing = true, начинает refresh
  2. Последующие 401 → запросы помещаются в failedQueue (Promise)
  3. Refresh успешенprocessQueue(null) разрешает все Promise в очереди
  4. Refresh неудаченprocessQueue(error) отклоняет все Promise

Защита от бесконечного цикла

  • originalRequest._retry = true — запрос не будет повторяться дважды
  • Проверка URL: /auth/refresh и /auth/login не обрабатываются interceptor'ом
  • isRefreshing — предотвращает параллельные refresh-запросы

Взаимодействие с Zustand Store

useAuthStore.getState() → { accessToken, refreshToken, setTokens, logout }
  • accessToken — вставляется в каждый запрос (request interceptor)
  • refreshToken — используется для обновления
  • setTokens() — обновляет оба токена после refresh
  • logout() — очищает данные при неудачном refresh