Axios의 interceptors 기능을 통해 access token을 갱신하는 로직을 구현하는 과정에서,
request에서 token이 만료되었을 때 새로운 토큰을 갱신하는 로직을
response interceptors에 구현해놓은 상태였다.
그런데, 여러가지의 request를 동시에 병렬적으로 요청하는 로직에서 오류가 발생했다.
예를들어 request1, request2, request3가 모두 만료된 토큰을 가지고 실행되었을 때,
request1이 refresh_token을 통해 새로운 토큰을 발급받아서 new_access_token과 new_refresh_token을 발급받아서 request를 다시 요청하는 것까지는 문제가 없다.
그런데 request2부터는 access_token을 새로 발급받기 위해서 refresh_token을 통해서 새로운 token을 발급받는 시도를 하게 되는데, 이미 request1으로 인해 new_refresh_token이 발급되고 기존 refresh_token은 만료된 상태이기 때문에,
request2와 request3는 refresh token이 만료되는 에러 메시지를 만나게 된다.
따라서, 위와 같이 여러 리퀘스트가 동시에 들어가게 되는 로직을 고려해서 토큰 갱신 로직을 수정하게 되었다.
사용된 패턴은 single-flight 메커니즘이라 칭한다.(고 한다. by ChatGPT - Go 언어에서 언급된 패턴이라 함)
위 메커니즘에 따른 request 처리 절차는 다음과 같다.
1. (만료된 access token으로 인해) 새로운 토큰을 담아서 보내야 하는 request들을 array에 모아둔다.
2. 새로운 토큰을 발급받으면 array에 모아둔 request들을 모두 새로운 access token으로 교체하고
서버에 request를 보낸다.
방식을 큰 줄기만 나타내면 위와 같이 복잡하지 않은데, 위 방식을 구현하는 과정에서
Promise를 활용하고, axios의 interceptors의 동작을 제어하는 코드에 대한 이해가 필요하다.
우선 코드는 다음과 같다.
let isRefreshing = false;
let refreshSubscribers = [];
// Function to subscribe other requests to wait for the token refresh
function subscribeTokenRefresh(cb) {
refreshSubscribers.push(cb);
}
// Function to notify all subscribed requests once the token is refreshed
function onRefreshed(newToken) {
refreshSubscribers.forEach(cb => cb(newToken));
refreshSubscribers = [];
}
serverApi.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
const tokens = await getTokens();
if (error.response.status === 401 && tokens && tokens.refresh_token) {
if (!isRefreshing) {
isRefreshing = true;
try {
const newTokens = await renewTokenRequest(tokens.refresh_token);
const { access_token, refresh_token, access_token_expire_at, refresh_token_expire_at } = newTokens;
const saveTokenResult = await saveTokens(access_token, refresh_token, access_token_expire_at, refresh_token_expire_at);
originalRequest.headers.Authorization = `Bearer ${access_token}`;
isRefreshing = false;
onRefreshed(access_token);
return serverApi(originalRequest);
} catch(error) {
const response = error.response;
if (response.status === 401 && response.data.detail.includes("Expired")) {
delete originalRequest.headers.Authorization;
const logoutResult = await logout();
...
} else {
console.error(error);
}
}
}
const retryOriginalRequest = new Promise((resolve) => {
subscribeTokenRefresh((access_token) => {
originalRequest.headers.Authorization = `Bearer ${access_token}`;
resolve(serverApi(originalRequest));
});
});
return retryOriginalRequest;
} else {
console.error("response interceptors error", error);
}
return Promise.reject(error);
}
);
subscribeTokenRefresh 함수는 cb (콜백 함수)를 받도록 선언되어 있다.
function subscribeTokenRefresh(cb) {
refreshSubscribers.push(cb);
}
위 함수가 response interceptors 안에서 다음과 같이 Promise 객체 선언에서 사용되고 있다.
const retryOriginalRequest = new Promise((resolve) => {
subscribeTokenRefresh((access_token) => {
originalRequest.headers.Authorization = `Bearer ${access_token}`;
resolve(serverApi(originalRequest));
});
});
return retryOriginalRequest;
access_token을 인자로 받아서, Authorization을 교체하고, Promise의 resolve를 실행하는 콜백 함수를 array에 담고,
(위의 콜백함수: (access_token) => {...; resolve}의 arrow function)
response interceptors는 위 Promise (retryOriginalRequest)를 return 한다.
이 부분에서 axios의 동작 원리에 대한 이해가 필요한데,
axios interceptors에서 Promise.resolve()나, Promise.reject()를 return 하는 것이 아니라
Promise를 return하면, resolve (또는 reject)가 실행되기 전까지 대기 상태에 놓이게 된다.
-> 따라서 위 코드에서 return retryOriginalRequest로 인해 request가 다시 보내지는게 아니라,
해당 request를 보내는 로직을 담은 콜백함수가 array에 담겨져있게 된다.
array에 담긴 request를 새로운 토큰으로 보내는 함수는 function onRefreshed(newToken)이다.
위 함수를 보면 forEach문을 통해 cb 함수들이 새로운 토큰(newToken)을 받아서 보내고 array를 비우도록 되어있다.
이제 위 로직이 어떤 순서로 동작하는지 예를 들어보겠다.
request1, request2, request3가 순서대로 들어왔고, 모두 만료된 access token을 가지고 있는 경우를 보자.
request1이 401 error로 request가 fail을 해서 response interceptors 로직이 실행된다.
isRefreshing이 false 상태이므로 if(!isRefreshing) 조건문안으로 들어와서 token을 refresh하는 로직이 실행된다.
request2와 request3 역시 401 error로 response interceptors 안으로 들어오지만, request1이 이미 isRefreshing을 true로 변경했기에 request2와 request3는 콜백함수(각각 request1_cb, request2_cb라 하자)를 array에 담는 로직으로 들어간다.
이때 request1으로 인한 response interceptors에서는 토큰 refresh를 진행하고 있고,
refreshSubscribers array에는 [request2_cb, request3_cb]의 형태로 콜백함수들이 담겨져있는 상태이다.
이후, request1으로 인한 interceptors에서 토큰 갱신이 완료되면, onRefresh에 새로운 access_token을 넣고 실행해서, request2_cb, request3_cb가 위 토큰으로 새로운 request를 보내게 된다.
이때, Promise의 resolve가 실행되므로 대기하고 있던 axios의 request가 서버로 보내지게 된다.
request1은 array에 담기지 않고, 조건문 안에서 바로 return serverApi(originalRequest)를 통해 새로운 토큰으로 request를 보내게 된다.
* Promise를 이용해서 토큰 갱신에 single-flight 메커니즘을 적용하는 예를 들어보았다.
Promise의 resolve, reject에 대한 이해
axios의 interceptors의 return 값에 따른 동작의 이해
비동기로직의 처리로 인한 request 1,2,3의 동작 순서를 이해하는 것이 이해하는데 필요하다.
* 추가 예외 상황
병렬적으로 처리하는 request가 훨씬 많아지거나, 로직 처리에 시간이 걸려서 첫 response interceptors에서 토큰 갱신이
완료되고 그 이후에 만료된 토큰으로 인한 response 오류로 인해 response interceptors가 추가적으로 실행되는 경우가 있을 수 있다.
구체적으로 예를 들어보자.
request 1,2,3가 새로운 request로 보내진 후에 request4가 늦게 interceptors 안으로 들어왔다고 하자.
이 경우는 request4가 request1과 같이 if(!isRefreshing) 조건문 안의 로직을 탈 것이기 때문에,
오류가 나지는 않는다.
다만, 토큰이 새로 갱신되게 된다. 이를 처리하는 추가적인 로직은 생략한다.
커밋내역
Claude 질의 응답
Q: In axios interceptors, if I return Promise, not Promise.resolve() or Promise.reject(), then axios waits to send request until the Promise's resolve or reject are executed?
A:
Let's break this down to understand how Axios interceptors work with different types of returned values:
- If you return a Promise in an Axios interceptor:
- Axios will wait for that Promise to settle (either resolve or reject) before proceeding.
- The request won't be sent until the Promise is resolved or rejected.
- If you return Promise.resolve() or Promise.reject():
- These are already settled Promises, so Axios will proceed immediately.
- If you return any other value (not a Promise):
- Axios will treat it as if you returned Promise.resolve(value).
Here's a simple example to illustrate:
axios.interceptors.request.use(config => {
return new Promise(resolve => {
setTimeout(() => {
console.log("Interceptor finished after 2 seconds");
resolve(config);
}, 2000);
});
});
axios.get('https://api.example.com/data')
.then(response => console.log("Request completed"));
In this case, the request won't be sent until after the 2-second delay in the interceptor.
So, to directly answer your question: Yes, if you return a Promise (not Promise.resolve() or Promise.reject()), Axios will wait for that Promise to settle before sending the request.
ChatGPT 질의 응답
Q:
I have parallel request in react native axios. And if token is expired, they refresh token parellel and I don't want that. When paraellel requests are executed, but I want only one refresh token request, is debounce best answer?
A:
In your case, debouncing may not be the best solution, since it's typically used to limit how often a function is invoked within a short period of time, and it wouldn't necessarily prevent multiple simultaneous requests from being fired when multiple parallel requests are made.
A better solution for ensuring only one refresh token request is made is to use a single-flight mechanism, such as a promise-based solution where the first request that detects an expired token triggers the token refresh, and subsequent requests wait for that token refresh to complete before proceeding.
Here’s an example approach using Axios interceptors and a promise queue:
import axios from 'axios';
let isRefreshing = false;
let refreshSubscribers = [];
// Function to subscribe other requests to wait for the token refresh
function subscribeTokenRefresh(cb) {
refreshSubscribers.push(cb);
}
// Function to notify all subscribed requests once the token is refreshed
function onRrefreshed(newToken) {
refreshSubscribers.forEach(cb => cb(newToken));
refreshSubscribers = [];
}
// Create an axios instance
const axiosInstance = axios.create({
baseURL: 'https://your-api-url.com',
});
// Request interceptor to handle token expiration and refreshing
axiosInstance.interceptors.response.use(
response => response,
async error => {
const { config, response } = error;
if (response && response.status === 401) {
if (!isRefreshing) {
isRefreshing = true;
try {
const refreshToken = 'your-refresh-token';
const newTokenResponse = await axios.post('/refresh-token-endpoint', { token: refreshToken });
const newToken = newTokenResponse.data.token;
// Update the token in your storage
// E.g. AsyncStorage.setItem('access_token', newToken);
axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
isRefreshing = false;
onRrefreshed(newToken);
} catch (refreshError) {
return Promise.reject(refreshError);
}
}
// Queue the request to wait until the token is refreshed
const retryOriginalRequest = new Promise(resolve => {
subscribeTokenRefresh(token => {
config.headers['Authorization'] = `Bearer ${token}`;
resolve(axiosInstance(config));
});
});
return retryOriginalRequest;
}
return Promise.reject(error);
}
);
export default axiosInstance;
How it works:
- isRefreshing flag ensures only one token refresh request is made.
- refreshSubscribers is an array of functions (callbacks) waiting for the token refresh.
- When the token expires, only the first request triggers the token refresh. Other requests subscribe to the refresh and are re-executed once the new token is available.
This approach ensures that even with multiple parallel requests, only one token refresh happens, and the other requests wait for it to complete.
Q:
Then, determine that my description is right. request1, request2, request3 are failed with 401 error and request1 gets in first in refreshing token because first isRefreshing is false. while request1 is refreshing token, request2 and request3 are inserted in refreshSubscribers like this [request2_cb, request3_cb] by return retryOriginalRequest. After onRefreshed, request2_cb and request3_cb in refreshSubscribers are executed and request1 is executed by return serverApi(originalRequest). Overthere, request1 is never instered in refreshSubscribers, right? And, request2_cb and request3_cb execute Promise's resolve, so axios requests that was in waiting status are executed with new access token. Is that all right?
A:
Yes, your understanding is correct! Let’s walk through each part of your description to confirm that it aligns with the flow:
1. request1, request2, and request3 all fail with 401 errors:
- request1 detects that the token has expired and since isRefreshing is initially false, request1 starts the token refresh process.
- At this point, isRefreshing is set to true to signal that a token refresh is in progress.
2. While request1 is refreshing the token:
- request2 and request3 also fail with 401, but because isRefreshing = true (set by request1), they do not trigger their own token refresh.
- Instead, both request2 and request3 are inserted into the refreshSubscribers array using subscribeTokenRefresh. Each callback (i.e., request2_cb and request3_cb) will be called after the token refresh completes.
- refreshSubscribers = [request2_cb, request3_cb]
- request1 is NOT inserted into refreshSubscribers, as request1 is the one actually performing the token refresh.
- At this point:
3. After request1 successfully refreshes the token:
- The token refresh process completes, and onRrefreshed(newToken) is called.
- onRrefreshed iterates through refreshSubscribers and executes each callback (request2_cb and request3_cb), passing the new token to them.
- request2_cb and request3_cb will now:
- Update their Authorization headers with the new token.
- Retry their original requests with serverApi(originalRequest).
- request2_cb and request3_cb will now:
4. request2_cb and request3_cb execute Promise.resolve:
- Since request2_cb and request3_cb resolve their respective Promises, Axios, which was waiting on those Promises to resolve, will now send the requests with the new token.
- So, request2 and request3 are retried with the new access token, and they proceed with their requests normally.
5. request1 completes its request without being in refreshSubscribers:
- request1 was never inserted into refreshSubscribers, because it was responsible for refreshing the token.
- After the token refresh is completed, request1 continues by retrying its original request with the new token (via the resolve(serverApi(originalRequest)) inside its Promise).
Summary of Your Description:
- request1 starts the token refresh, and while it’s refreshing, request2 and request3 are inserted into refreshSubscribers.
- After the token is refreshed, request2_cb and request3_cb are executed from refreshSubscribers and retry their respective requests with the new token.
- request1 is not inserted into refreshSubscribers. Instead, it retries its request directly after refreshing the token.
- All requests (request1, request2, request3) are retried with the new token, and Axios proceeds with these requests as soon as the Promises are resolved.
Conclusion:
Yes, your description is correct! The flow you described matches how Axios handles multiple requests during a token refresh scenario.
Q:
Is that mechanism called single-flight? where does it comes from?
A:
Yes, the mechanism you're describing is commonly referred to as single-flight. The term "single-flight" originates from the idea that only one operation (or "flight") should be executed for a given task at a time, and any other tasks that come in while the first one is still in progress should "wait" for the result of the first task rather than initiating their own operation.
Origins of "Single-Flight":
- The term "single-flight" was popularized in the Go programming language by a package called sync/singleflight. In this package, multiple concurrent requests for the same resource are coalesced into a single request. Once the initial request completes, the result is shared with all other requests, avoiding multiple redundant operations.
- The concept, however, is not unique to Go. The pattern is widely applicable to any situation where you want to consolidate duplicate work into a single execution, particularly in scenarios like token refresh, caching, or network requests.
How Single-Flight Works in Your Scenario:
In the token refresh mechanism that you described:
- When multiple requests (e.g., request1, request2, request3) encounter a token expiration (401 error), only the first request (request1) triggers the token refresh.
- Other requests (like request2 and request3) do not trigger additional refreshes; instead, they "wait" for the result of request1.
- Once the token is refreshed, all waiting requests are retried with the new token.
This is a classic single-flight pattern because:
- Only one flight (token refresh) is allowed at a time, while subsequent requests "subscribe" to the result of that flight.
- Redundant refreshes are avoided, and resources (like server load) are conserved.
Benefits of Single-Flight:
- Efficiency: It prevents redundant operations, which is particularly important in situations where the task is expensive (like refreshing a token or fetching a large resource).
- Concurrency Handling: It handles multiple concurrent requests gracefully by ensuring that only one "flight" happens at a time for a given resource.
- Simplified Code Logic: You don't have to manage multiple refresh requests and responses; everything is handled in one operation, and other tasks simply subscribe to it.
Where Else is Single-Flight Used?
- Caching systems: When multiple users or processes request the same data, a single-flight mechanism can be used to ensure that only one data retrieval request is sent to the server, and all others wait for the result.
- API Rate Limiting: To avoid making too many requests to an external API at once, a single-flight pattern can be used to coalesce multiple identical requests.
- Database access: To reduce the load on a database when many processes or users request the same data at once, single-flight ensures that only one query is executed.
Conclusion:
Yes, your mechanism for refreshing the token when only one request initiates the refresh and others wait for the result is a perfect example of the single-flight pattern. The term comes from the Go programming language, but the concept is broadly applicable in many areas of software development where concurrency and resource efficiency are important.