Axios 完整封裝
axios完整封裝,並整合 axios-retry 實現自動重試機制,同時安全地處理 Access Token 過期後的「單航班」刷新(single-flight refresh)邏輯

封裝 Axios、整合 axios-retry 與自動刷新 Token
本文件示範如何在前端專案中,將 axios 完整封裝,並整合 axios-retry 實現自動重試機制,同時安全地處理 Access Token 過期後的「單航班」刷新(single-flight refresh)邏輯,避免並發重刷問題。以 Vue 3 + Vite + TypeScript 為例,但概念適用於任意前端框架。
為什麼要封裝 Axios?
統一設定:
baseURL、timeout、預設headers、錯誤處理在一處維護。自動帶入 Token:在 Request Interceptor 注入
Authorization。自動刷新 Token:在 Response Interceptor 遇到 401 時,單點刷新並重新送出原請求。
自動重試:以
axios-retry對暫時性錯誤(網路、5xx)做回退重試(exponential backoff)。
安裝套件
npm i -D axios axios-retry
# 或
pnpm add -D axios axios-retry
建議目錄結構
src/lib/http.ts:建立與導出封裝後的 axios 實例與型別。src/lib/auth.ts:負責 Token 存取(可接 Pinia/Redux/LocalStorage)。src/api/*.ts:各領域 API 模組,引用http實例。
Token 存取模組(範例:src/lib/auth.ts)
此處以 LocalStorage 為簡化示範,實務上建議接上狀態管理與更嚴謹的安全策略(例如使用 httpOnly Cookie 搭配 CSR/SSR 需求權衡)。
// src/lib/auth.ts
export interface Tokens {
accessToken: string;
refreshToken: string;
}
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function setTokens(tokens: Partial<Tokens>): void {
if (typeof tokens.accessToken === 'string') {
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken);
}
if (typeof tokens.refreshToken === 'string') {
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
}
}
export function clearTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
封裝 Axios 與整合 axios-retry(src/lib/http.ts)
設定
axios-retry:預設只對「網路錯誤」與「5xx」重試,避免對 4xx 盲目重試。Request Interceptor:自動附加
Authorization: Bearer <accessToken>。Response Interceptor:攔 401,執行單航班刷新;刷新成功後重送原請求,否則登出/清 Token。
避免重複刷新:以
isRefreshing與pendingRequests佇列實作。
// src/lib/http.ts
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import axiosRetry, { isNetworkOrIdempotentRequestError } from 'axios-retry';
import { getAccessToken, getRefreshToken, setTokens, clearTokens } from './auth';
declare module 'axios' {
// 為原請求加上自定義旗標,避免重複行為或識別來源
export interface AxiosRequestConfig {
_retry?: boolean;
_isRefreshCall?: boolean;
_skipAuth?: boolean;
}
}
export interface RefreshResponse {
accessToken: string;
refreshToken?: string;
}
const BASE_URL = import.meta?.env?.VITE_API_BASE_URL ?? '/api';
const http: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: 15_000,
withCredentials: false, // 若使用 httpOnly Cookie,依需求設為 true
});
// 配置 axios-retry(指數回退),針對暫時性錯誤重試
axiosRetry(http, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
// 僅重試網路錯誤與 5xx;避免對 401/403 這類授權錯誤盲目重試
retryCondition: (error) => {
// 不重試刷新 Token 的請求,避免迴圈
const isRefreshCall = (error?.config as AxiosRequestConfig | undefined)?._isRefreshCall;
if (isRefreshCall) return false;
// 預設條件:網路錯誤或 idempotent 請求失敗(5xx)
if (isNetworkOrIdempotentRequestError(error)) return true;
// 可選:對 429 Too Many Requests 也做回退重試
if (axios.isAxiosError(error) && error.response?.status === 429) return true;
return false;
},
});
// ------- 單航班刷新邏輯 -------
let isRefreshing = false;
let pendingRequests: Array<(token: string | null) => void> = [];
function subscribeTokenRefresh(cb: (token: string | null) => void) {
pendingRequests.push(cb);
}
function onRefreshed(newAccessToken: string | null) {
pendingRequests.forEach((cb) => cb(newAccessToken));
pendingRequests = [];
}
async function refreshToken(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken) return null;
try {
const resp = await http.post<RefreshResponse>(
'/auth/refresh',
{ refreshToken },
{ _isRefreshCall: true, _skipAuth: true }
);
const { accessToken, refreshToken: nextRefresh } = resp.data;
setTokens({ accessToken, refreshToken: nextRefresh });
return accessToken;
} catch (err) {
return null;
}
}
// ------- 請求攔截:自動帶 Token -------
http.interceptors.request.use((config) => {
if (!config._skipAuth) {
const token = getAccessToken();
if (token) {
config.headers = config.headers ?? {};
(config.headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
}
return config;
});
// ------- 回應攔截:統一處理成功/錯誤、401 刷新 -------
http.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig | undefined;
const status = error.response?.status;
// 非 401 或無原請求,交給 axios-retry 或呼叫端處理
if (status !== 401 || !originalRequest) {
return Promise.reject(error);
}
// 避免同一請求重複刷新
if (originalRequest._retry) {
return Promise.reject(error);
}
originalRequest._retry = true;
// 若已在刷新中,將當前請求掛起,待刷新完成後重送
if (isRefreshing) {
return new Promise((resolve, reject) => {
subscribeTokenRefresh((newToken) => {
if (!newToken) {
reject(error);
return;
}
// 更新 Authorization 後重送原請求
originalRequest.headers = originalRequest.headers ?? {};
(originalRequest.headers as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
resolve(http(originalRequest));
});
});
}
// 開始刷新
isRefreshing = true;
try {
const newToken = await refreshToken();
onRefreshed(newToken);
if (!newToken) {
// 刷新失敗:清理 Token 並把錯誤往外拋,讓呼叫端做登出或跳轉
clearTokens();
throw error;
}
// 搭載新 Token 重送原請求
originalRequest.headers = originalRequest.headers ?? {};
(originalRequest.headers as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
return http(originalRequest);
} catch (e) {
throw e;
} finally {
isRefreshing = false;
}
}
);
export default http;
在 API 模組中使用(src/api/user.ts)
// src/api/user.ts
import http from '@/lib/http';
export interface UserProfile {
id: string;
email: string;
name: string;
}
export async function fetchMyProfile(): Promise<UserProfile> {
const { data } = await http.get<UserProfile>('/users/me');
return data;
}
在 Vue 組件或 Store 中呼叫
// 例:某個 component 或 Pinia action
import { fetchMyProfile } from '@/api/user';
async function load() {
try {
const me = await fetchMyProfile();
console.log(me);
} catch (err) {
// 若刷新失敗導致 401,這裡可以導向登入頁或顯示提示
// router.push({ name: 'login' });
}
}
重要細節與最佳實務
避免刷新請求再被重試:對刷新 API 設
config._isRefreshCall = true,讓axios-retry跳過它,避免迴圈。只重試暫時性錯誤:
axios-retry的retryCondition預設只處理網路/5xx,避免 4xx(授權類)被無謂重試。單航班刷新:使用
isRefreshing與pendingRequests,避免多個 401 併發時重複呼叫刷新端點。安全性:若可行,建議改以
httpOnlyCookie 存放敏感 Token,並在後端實作 CSRF 防護;本文為簡化示範。SSR 注意:若為 SSR,請避免在伺服器端使用全域
localStorage;可在請求範圍注入 Token(per-request context)。取消請求:對長時間請求建議加
AbortController/CancelToken,以免無謂重試佔用資源。Idempotent 請求:重試機制理應只用於安全可重送的請求(GET 或具冪等語義的操作)。
常見錯誤排查
刷新 API 路徑或回傳型別不一致:請調整
refreshToken()內的請求路徑與RefreshResponse型別。重試無效:檢查
axios-retry是否正確套用於同一個http實例,或retryCondition是否過於嚴格。無限 401 迴圈:確認
originalRequest._retry是否生效、刷新請求是否被排除、以及刷新成功後是否確實更新了Authorization。顯示已刷新仍然 401:後端可能需要一定的 Token 傳播時間,或客戶端忽略了覆寫
Authorization後立即重送。
單元測試與端對端測試建議
以
msw模擬 API,針對下列情境撰寫測試:正常 200 回應
5xx 下
axios-retry回退重試401 後成功刷新並重送原請求
401 後刷新失敗,最終拋錯(應清 Token 與導向登入)
併發多個 401 僅觸發一次刷新(單航班)
延伸:更進階的需求
針對不同服務建立多個
http實例,用不同baseURL與憑證策略。,在retryCondition添加對自定義錯誤碼(如X-Retryable: true)的判斷。將
pendingRequests以Promise封裝為「刷新完成通知器」,提升可讀性。將 Token 存取改為抽象介面,方便在 Web/Native/Node 共用。

