Kịch bản hay gặp: màn hình home gọi 5 API cùng lúc, token vừa hết hạn → cả 5 nhận 401. Nếu mỗi request tự refresh, server nhận 5 lần refresh. Với refresh token rotation (RFC 9700 yêu cầu public client như app mobile dùng rotation hoặc sender-constrained token), refresh token cũ bị vô hiệu sau lần dùng đầu → các lần sau bị coi là dùng lại token → server có thể thu hồi cả phiên, người dùng bị logout.
Giải pháp là single-flight: chỉ một request refresh chạy, các request 401 khác chờ chung kết quả rồi retry:
let refreshing: Promise<string> | null = null
api.interceptors.response.use(undefined, async (error) => {
const original = error.config
if (error.response?.status !== 401 || original._retry) throw error
original._retry = true // retry once, never loop
// Every 401 awaits the same in-flight refresh; a failed refresh rejects all of them
refreshing ??= refreshAccessToken()
.catch((e) => { signOut(); throw e }) // refresh token expired/revoked
.finally(() => { refreshing = null })
const token = await refreshing
original.headers.Authorization = `Bearer ${token}`
return api(original)
})Các điểm cần nói thêm:
- Request refresh gọi bằng instance riêng không gắn interceptor, tránh vòng lặp 401 → refresh → 401.
- Refresh thất bại (refresh token hết hạn/bị thu hồi) → signOut() chạy đúng một lần: xoá token, đưa về màn hình đăng nhập; mọi request đang chờ cùng nhận lỗi.
- 401 về muộn sau khi refresh đã xong (request gửi bằng token cũ) → so token trong header với token hiện tại; khác thì retry luôn bằng token mới, không refresh thêm lần nữa.
- Có thể refresh chủ động trước khi exp hết hạn vài chục giây để giảm số 401.
- Token lưu trong Keychain/Keystore, không để AsyncStorage plaintext.
- Android native: cùng ý tưởng với OkHttp Authenticator + đồng bộ hoá (synchronized/Mutex).
Lưu ý: chỉ retry request idempotent hoặc có idempotency key — retry một lệnh chuyển tiền khi chưa rõ lần đầu đã thành công hay chưa có thể trừ tiền hai lần.