All files / src/api client.ts

100% Statements 84/84
100% Branches 26/26
100% Functions 3/3
100% Lines 84/84

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 1131x 1x 1x   1x 1x 1x     1x   6x 6x 2x 1x 1x 1x 1x 6x 6x 6x   1x 1x 1x 1x 1x 1x   1x 5x     5x 2x 2x 1x 1x 1x     1x 2x   5x 3x 3x 4x 4x   1x 1x 1x 10x     10x 10x 9x 9x 10x   8x 2x 2x 2x 2x 1x 1x 1x 1x 1x 2x   6x 6x   6x     6x 6x 6x 8x 1x 1x 8x 4x   4x 4x 4x 4x 4x     2x 2x   2x 2x       2x 2x   2x 10x 1x   1x  
import axios from "axios";
import { useAuthStore } from "../stores/authStore";
import { refreshSession, isTokenExpiringSoon } from "./auth";
 
let isRefreshing = false;
let refreshPromise: Promise<string> | null = null;
let failedQueue: Array<{
  resolve: (token: string) => void;
  reject: (err: unknown) => void;
}> = [];
 
function processQueue(error: unknown, token: string | null = null) {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else if (token) {
      prom.resolve(token);
    }
  });
  failedQueue = [];
}
 
const apiClient = axios.create({
  baseURL: "http://localhost:8000/api/v1",
  headers: {
    "Content-Type": "application/json",
  },
});
 
apiClient.interceptors.request.use(async (config) => {
  const token = localStorage.getItem("access_token");
 
  // Proactive refresh: if token is expiring soon and this isn't the refresh call itself
  if (token && isTokenExpiringSoon() && !config.url?.includes("/auth/")) {
    try {
      const newToken = await refreshSession();
      config.headers.Authorization = `Bearer ${newToken}`;
      return config;
    } catch {
      // Proactive refresh failed; let response interceptor handle 401
      void 0;
    }
  }
 
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});
 
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
 
    // Only handle 401s that haven't been retried yet
    if (
      error.response?.status === 401 &&
      originalRequest &&
      !originalRequest._retry
    ) {
      // If already refreshing, queue this request
      if (isRefreshing) {
        try {
          const token = await new Promise<string>((resolve, reject) => {
            failedQueue.push({ resolve, reject });
          });
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return apiClient(originalRequest);
        } catch (err) {
          return Promise.reject(err);
        }
      }
 
      originalRequest._retry = true;
      isRefreshing = true;
 
      let newToken: string;
 
      // Only wrap the actual refresh operation in the try/catch
      try {
        refreshPromise = refreshSession();
        newToken = await refreshPromise;
        if (!newToken) {
          throw new Error("Session refresh returned empty token");
        }
      } catch (refreshError) {
        processQueue(refreshError, null);
        // Graceful state wipe triggering React Router redirect
        useAuthStore.getState().logout();
        isRefreshing = false;
        refreshPromise = null;
        return Promise.reject(refreshError);
      }
 
      // Success path execution
      isRefreshing = false;
      refreshPromise = null;
 
      processQueue(null, newToken);
      originalRequest.headers.Authorization = `Bearer ${newToken}`;
 
      // Returning the un-awaited Promise here avoids catching retry failures
      // as "refresh failures" and safely sidesteps the V8 coverage glitch.
      return apiClient(originalRequest);
    }
 
    return Promise.reject(error);
  },
);
 
export default apiClient;