GraphQL请求失败?Token过期问题深度解析与解决方案
2025-03-09 07:47:06
Token 过期导致 GraphQL 请求失败:深入分析与解决方案
在使用 GraphQL 和 Apollo Client 进行开发时,我们经常会遇到 Token 过期的问题。如果 Token 过期后,在发起 GraphQL 请求前没有正确刷新 Token,就会导致请求失败。 问题来了,明明写了 setContext
在每次请求前获取 token,为啥还是和 GraphQL API 同时发出了刷新 token 的请求?
问题原因剖析
先看看这段代码, 问题可能出在 setContext
和 commonAuthHeaders
函数的实现上,尤其与异步操作的处理方式有关。
const authLink = setContext((_, { headers }) => {
return new Promise( async (resolve, reject) => {
// get the authentication headers
const updatedHeaders = await commonAuthHeaders(headers)
resolve ({
headers: updatedHeaders
}
)
});
});
export const client = new ApolloClient({
link: from([authLink, errorLink , httpLink]),
cache,
connectToDevTools: process.env.NODE_ENV !== "production",
defaultOptions: {
watchQuery: {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
},
},
});
export async function commonAuthHeaders(headers) {
const supportedLoginModes = ["autodesk", "google"]
const logInMode = getCookie("__login_mode");
let access_token = getCookie("__bauhub_token");
const refresh_token = getCookie("__bauhub_refresh_token")
const lang = getCookie("__bauhub_lang");
const currentOrgId = getCookie("__current_organization");
const pluginType = getCookie("pluginType")
const newHeaders = {
...headers,
"Apollo-Require-Preflight": true,
};
if (lang) newHeaders["Accept-Language"] = lang;
if (supportedLoginModes.includes(logInMode)) {
if (logInMode === "autodesk" && !access_token && refresh_token) {
access_token = await refreshAutodeskAccessToken()
}
if (logInMode === "google" && !access_token) {
access_token = await getFirebaseToken()
}
if (access_token && currentOrgId) newHeaders.organizationId = currentOrgId;
if (logInMode && access_token) newHeaders.logInMode = logInMode;
if (pluginType) newHeaders.pluginType = pluginType
newHeaders.authorization = access_token ? `Bearer ${access_token}` : "";
return newHeaders
} else {
return headers
}
}
代码中,setContext
确实返回了一个 Promise,保证了异步获取 header 的操作。 看起来,应该先等 commonAuthHeaders
执行完毕拿到 updatedHeaders
之后, 才把包含新 token 的 updatedHeaders
放进请求。
问题偏偏出在并发控制。 虽然 authLink
用了 async/await,commonAuthHeaders
内部也用了 async/await (例如 refreshAutodeskAccessToken()
和 getFirebaseToken()
)。 但, 如果有多个 GraphQL 请求同时发出,就有可能出现多个请求同时触发 authLink
, 进而同时进入 commonAuthHeaders
,此时大家一起去检查 access_token
,发现都过期了,然后一起调用刷新 Token 的 API 。
结果就变成了:Token 刷新请求和 GraphQL 请求一起发出。
解决方案
要解决这个问题, 需要确保在同一时刻,只有一个请求能去刷新 Token。其他请求咋办? 等! 等到拿到新的 Token 再继续。 下面给出几种可行的方案。
1. 使用 Semaphore (信号量)
信号量是一种经典的同步机制,可以限制同时访问某个资源的线程数量。 用在这里, 可以保证同一时刻只有一个请求在执行刷新 Token 的操作。
原理:
- 创建一个初始值为 1 的信号量。
- 在
commonAuthHeaders
函数中,刷新 Token 前,尝试获取信号量。- 如果获取成功(当前没有其他请求在刷新 Token),则执行刷新操作。
- 如果获取失败(已有其他请求在刷新),则等待,直到信号量被释放。
- 刷新 Token 完成后,释放信号量。
代码示例 (使用 async-mutex
库):
import { Mutex } from 'async-mutex';
const mutex = new Mutex();
export async function commonAuthHeaders(headers) {
const supportedLoginModes = ["autodesk", "google"];
const logInMode = getCookie("__login_mode");
let access_token = getCookie("__bauhub_token");
const refresh_token = getCookie("__bauhub_refresh_token");
const lang = getCookie("__bauhub_lang");
const currentOrgId = getCookie("__current_organization");
const pluginType = getCookie("pluginType");
const newHeaders = {
...headers,
"Apollo-Require-Preflight": true,
};
if (lang) newHeaders["Accept-Language"] = lang;
if (supportedLoginModes.includes(logInMode)) {
let release; // 用来存放 mutex 的 release 方法
if ((logInMode === "autodesk" && !access_token && refresh_token) || (logInMode === "google" && !access_token)) {
release = await mutex.acquire(); // 获取锁
try{
//重新从cookie里取一下, 避免多个请求同时刷新
access_token = getCookie("__bauhub_token");
if (logInMode === "autodesk" && !access_token && refresh_token) {
access_token = await refreshAutodeskAccessToken();
}
if (logInMode === "google" && !access_token) {
access_token = await getFirebaseToken();
}
}finally{
release(); // 刷新 Token 后,释放锁
}
}
if (access_token && currentOrgId) newHeaders.organizationId = currentOrgId;
if (logInMode && access_token) newHeaders.logInMode = logInMode;
if (pluginType) newHeaders.pluginType = pluginType;
newHeaders.authorization = access_token ? `Bearer ${access_token}` : "";
return newHeaders;
} else {
return headers;
}
}
额外建议:
将 mutex
对象放在一个单独的模块中,并导出,以便在整个应用程序中共享同一个锁。
2. Token 刷新队列
另一个方案, 是搞个队列。 想法是: 如果发现 Token 过期,不直接刷新,而是把当前的请求放到一个队列里。然后,检查下有没有正在刷新的请求,没有的话就发起一个刷新请求。 刷新成功后, 把队列里的请求都挨个执行一遍, 大家都用上新 Token。
原理:
- 维护一个请求队列。
- 如果发现 Token 过期:
- 将当前请求的回调函数(用于设置 headers)放入队列。
- 检查是否有正在进行的 Token 刷新请求。
- 如果没有,发起 Token 刷新请求。
- 如果有,则等待刷新完成。
- Token 刷新完成后:
- 从队列中取出所有回调函数,并使用新的 Token 执行它们。
代码示例(简略版,仅展示思路):
let refreshPromise = null;
let requestQueue = [];
async function refreshTokenAndProcessQueue() {
try{
const newAccessToken = await refreshAutodeskAccessToken(); // 或者 getFirebaseToken()
// 更新 cookie 或者其他存储
requestQueue.forEach(callback => callback(newAccessToken));
}catch (error){
//处理刷新token的错误
requestQueue.forEach(callback => callback(null, error)); // 可以传递 error
}finally{
requestQueue = []; // 清空队列
refreshPromise = null;
}
}
export async function commonAuthHeaders(headers) {
// ... (省略部分代码)
if (logInMode === "autodesk" && !access_token && refresh_token) {
return new Promise((resolve,reject) => {
requestQueue.push((newAccessToken, error)=>{
if(error){
reject(error)
return;
}
const updatedHeaders = {...headers, authorization: `Bearer ${newAccessToken}`};
//更新其他的 header
resolve(updatedHeaders)
});
if(!refreshPromise){
refreshPromise = refreshTokenAndProcessQueue();
}
})
}
if (logInMode === "google" && !access_token) {
//和上面类似处理
}
// ...
}
额外建议:
这个方法比较复杂,容易出错, 但好处是可以更细粒度地控制请求。比如实现请求重试, 或者区分不同的错误类型。
3. 使用第三方库
也有一些库, 封装好了 Token 刷新的逻辑, 你直接拿来用就行了。 比如 apollo-link-token-refresh
。
原理:
这类库通常会拦截需要认证的请求,检查 Token 是否过期。如果过期,则自动刷新 Token,并使用新的 Token 重新发起请求。
代码示例 (使用 apollo-link-token-refresh
) :
首先,安装:
npm install apollo-link-token-refresh jwt-decode
然后,在 Apollo Client 配置中使用:
import { ApolloClient, InMemoryCache, from } from '@apollo/client';
import { RefreshLink } from 'apollo-link-token-refresh';
import decode from 'jwt-decode'; // 用于解析 token 获取过期时间, 可以根据你的 token 类型修改
const refreshLink = new RefreshLink({
isTokenValidOrUndefined: () => {
const token = getCookie("__bauhub_token"); // 从 cookie 或者其他地方拿token
if(!token) return true; // 没token 认为是有效
const decodedToken = decode(token); // 你的token解析逻辑
return decodedToken.exp * 1000 > Date.now() + 30000; // 预留30秒 buffer, 免得临界情况出问题
},
fetchAccessToken: async () =>{
const logInMode = getCookie("__login_mode");
if(logInMode==="autodesk"){
return refreshAutodeskAccessToken()
}
if(logInMode==="google"){
return getFirebaseToken()
}
},
handleFetch: (accessToken) => {
// 更新cookie
setCookie("__bauhub_token",accessToken) //你的cookie 设置函数
},
handleResponse: (operation, accessTokenField) => response => {
// 处理 token 刷新后, 服务器返回的各种情况
return response;
},
handleError: err => {
// 处理刷新token失败, 比如 refresh token 也过期了
}
});
export const client = new ApolloClient({
link: from([refreshLink, authLink, errorLink, httpLink]), // 注意 refreshLink 要放在 authLink 前面
// ...其他配置
cache: new InMemoryCache()
})
进阶技巧:
可以根据不同的错误码 (比如 401, 403) , 在 handleError
里实现更精细的错误处理, 例如, 可以根据服务器返回的提示信息来判断是否需要用户重新登录。
总结
选择哪种方案,主要取决于你的项目具体情况和需求。如果不想自己处理复杂的并发逻辑,用现成的库是最省事的。 如果想对请求有更多的控制, 队列的方法更合适。 信号量的方法, 实现起来比较简单, 而且也够用了。 不管用哪个, 核心思想都是: 同一时间, 只允许一个请求去刷新 token, 保证请求的顺序和 token 的正确性。