問題
一份 HTTP client 封裝的註解寫著:「換發失敗、換發端點自己 401、或重送後仍 401 才登出」。前兩種成立,第三種做不到——使用者會一直卡在已失效的登入狀態,每個請求都失敗,卻不會被踢回登入頁。
為什麼不是「一收到 401 就登出」?因為 access token 本來就會正常過期。一收到 401 就登出,等於每過一段時間就把正在用的人踢出去。所以合理的流程是:先用 refresh token 換新的 access token、重送一次,重送之後還是 401 才登出。
這個 bug 是寫測試補分支覆蓋率時才發現的。正常使用下走不到這條路徑,只有 refresh token 也過期的那個窗口才會。
同一份設計我用 axios 與 ky 各實作了一次,只有 ky 那份中。
兩層原因疊在一起
第一層:重送的那一次沒有走同一條處理鏈。
// 修前
if (!isRefreshCall && !retriedRequests.has(request)) {
retriedRequests.add(request);
const newToken = await refreshAccessToken();
if (newToken) {
request.headers.set('Authorization', `Bearer ${newToken}`);
return ky(request); // ← 這裡就結束了
}
}
releaseIdempotencyKey(request.method, request.url);
if (browser) {
const { authStore } = await import('$features/auth/store');
authStore.logout(); // ← 永遠不會執行
}
return response;hook 裡的 ky(request) 用的是 ky 的預設匯出,也就是沒有經過 ky.create() 設定的原生 ky,不帶封裝層掛上去的這些 hook。ky 的 README 對 ky.create() 的描述是「建立一個全新預設值的實例,不繼承任何父實例」——反過來說,hook 是掛在你建出來的那個實例上的,預設匯出身上沒有。
而外層在 return ky(request) 就把控制權交出去了,後面那段登出程式碼形同死碼。
順帶一提,這個寫法不是憑空想出來的:ky 舊版 README 的 afterResponse 範例,就是「拿到新 token 後 return ky(request)」(ky v1.7.0 README)。範例本身沒問題,它重送完就結束,不需要再判斷結果;問題出在把它搬過來之後,又在 return 底下接了「重送失敗要登出」的邏輯。
第二層:就算把結果接住也不夠。
改成 const retried = await ky(request); if (retried.status !== 401) ... 仍然失敗。ky 的 throwHttpErrors 預設是 true,回應不是 2xx 就直接拋 HTTPError(ky README)。一拋就跳出整個 hook,同樣走不到登出。
解法
把重送的結果接住、關掉自動拋例外、自己判斷狀態:
const newToken = await refreshAccessToken();
if (newToken) {
request.headers.set('Authorization', `Bearer ${newToken}`);
// 這裡用的是未經設定的 ky,不會再跑一次這些 hook——
// 所以重送的結果要在這裡自己判斷,直接 return 的話
// 「重送後仍 401」就永遠走不到下面的登出。
// throwHttpErrors: false 是必要的:ky 預設對非 2xx 直接丟例外,
// 一丟就跳出這個 hook,同樣走不到登出。
const retried = await ky(request, { throwHttpErrors: false });
if (retried.status !== 401) {
releaseIdempotencyKey(request.method, request.url);
return retried;
}
}
// 落到這裡:換發失敗、或重送後仍 401 → 往下走登出改完之後補上的測試全過,包括「重送之後還是 401 就登出,不會無限換發」那一支。修之前實測的數字是:refresh 回了新 token、後端持續回 401 時,fetch 被呼叫 2 次、換發 1 次、登出 0 次。
當時用的 ky 版本沒有記下來(未查證:版本號)。新版 ky 另有一條路:v1.14.0 起可以在 afterResponse 裡 return ky.retry({request, code}) 強制重試,這次重試會遵守 retry.limit,也會經過 beforeRetry hook;README 也寫明 afterResponse 的 retryCount 每次重試會遞增(ky README、v1.14.0 release)。也就是說重試走的是實例自己的重試機制,而不是另開一個沒有 hook 的請求。這條路我沒有實際改過去測,上面的解法是當時驗證過的那一版。
為什麼 axios 那份沒事
axios 版本的同一段邏輯沒有這個問題:攔截器裡 return axiosInstance(original) 呼叫的是同一個實例,會重新走一次完整的攔截器鏈。重送後的 401 自然會再被同一段程式碼接住,再靠一個 _retriedAfterRefresh 旗標避免無限迴圈。
這是同一份設計在兩個生態各自實作時最容易漏掉的差異。我對 HTTP client 封裝的要求是:請求收在一層、對外介面一致、錯誤統一分類,至於底下用 axios 還是 ky 不做規定——兩邊都叫 httpClient、都做 401 換發,看起來是等價的。但對外介面一樣,不代表底層函式庫的控制流一樣。 行為的等價要另外驗,不能從介面等價推導出來。
怎麼預防再次發生
寫任何「攔截器內重送」的邏輯時,問三個問題:
- 重送的那一次,會不會再走一遍這些攔截器? 會的話要防無限迴圈(旗標或 WeakSet);不會的話,重送的結果要自己判斷,後續邏輯不能靠攔截器再跑一次。
- 底層函式庫會不會在我判斷之前先拋例外? ky 預設會(
throwHttpErrors),axios 預設也會——validateStatus的預設實作是status >= 200 && status < 300,其他一律 reject(axios README)。要自己判斷 status,就得先關掉。 return之後的程式碼還會執行嗎? 這個 bug 的本質是return ky(request)讓底下一整段登出邏輯變成死碼,而型別檢查、lint、單元測試都不會提醒你。
同一份邏輯在多個生態各自實作時,行為測試要每家各寫一份。
相關
- 前端使用哪些方法跟 API 互動:axios 回應攔截器統一處理 401 的基本寫法。那個範例一收到 401 就導回登入頁;這篇講的是加上「先換發、再重送」之後才會出現的問題。
- 驗收假綠形態目錄:這個 bug 是補分支覆蓋率才浮出來的,沒被走到的路徑,等於沒被驗證過。