多接口同时请求,如何刷新token,你学废了吗?

647 阅读4分钟

背景

2024年01月01日,接了个私活,赚点外快。2周的时间,理需求,设计页面,开发,联调,产品 - UI - 前端一把梭,忙里忙外,终于尘埃落定了,准备提交给甲方体验。2000块到手,美滋滋。

第二天,开了个视频会议,给甲方做了一波功能展示,然后大吹特吹了一波。甲方负责人连连说“OK,OK!”,会议过程中充满了欢声笑语。会议结束前,约定本周给甲方开几个演示账号,Happy!

第三天,将测试的脏数据清空,模拟线上环境,新增了一些真实的数据,并更新了配置文件 - 将token的有效期更新为10分钟。万事俱备,准备做最后一轮测试,然后不出意外的情况下,果然出意外了。系统突然弹到登陆页了,查了一下日志,是token过期了,但是刷新token的接口也调用了。而且完整复现这个bug,十分钟过期,严重影响客户体验,属于一级BUG

开发测试的时候,token都是不过期的

当天,甲方负责人在群里问:”小李,测试账号新增好了吗?“。问的我冒虚汗,头皮发麻。跟后端商量了一下,先放开token时间限制, 。时间紧迫,解决这个问题迫在眉睫~~

问题排查 & 分析

登陆后,获取token,保存在cookie中,正常。

然后8分钟获取刷新token接口,并替换token信息,正常。

问题点:刷新token接口与其他接口同步触发,刷新token的接口请求 & 返回的慢,其他接口就过期了,然后触发了进入过期的逻辑,弹到登陆页了

定位了问题了,就好办了,开搞!

// 最初的代码
/*
    提前2分钟去置换token
*/
service.interceptors.request.use(async (config) => {
  if (getToken()) {
    const expirationTime = getExpiration()
    const currentTime = Date.now().getTime();

    if (expirationTime - currentTime < 2 * 60 * 1000) { 
      try {
        const res = await axios.post('/refresh', { token: getRefreshToken() });
        let d = res.data.body || {}
        setToken(d.tokenType + " " + d.accessToken);
        setExpiration(d.expiresIn + currentTime);
        setRefreshToken(d.refreshToken);
      } catch (error) {
        console.error('Refresh Token Error', error);
      }
    }

    config.headers['Authorization'] = getToken()
  }

  return config;
}

进化版

于是打算在401的时候,重新发起请求,获取新的token

axios.interceptors.response.use(response => {
  // ...
}, error => {
  if (error.response.status === 401) {
    // isRefreshing 去控制是否在刷新token的状态,防止多次刷新
    if (!isRefreshing) {
      isRefreshing = true
      return axios.post('/refresh', { refreshToken })
        .then(res => {
          let d = res.data.body ||
            let token = d.tokenType + " " + d.accessToken
          setToken(token);
          setExpiration(d.expiresIn + Date.now().getTime());
          setRefreshToken(d.refreshToken);

          error.config.headers.Authorization = d.token
          return axios(config);
        })
        .catch(err => {
          removeToken()
          router.push('/login')
          return Promise.reject(err)
        })
        .finally(() => { 
          isRefreshing = false 
        })
    }
    return Promise.reject(error);
  });

经测试,多个接口如A接口,B接口,C接口发起请求时,

A接口401,置换token,B接口401,置换token

但是B置换的token,会刷新A置换的token。如果只是多请求几次刷新token,也是可以接受的关键问题还是同步请求顺序的不确定性,如:A刷新token接口请求,B刷新刷新token接口请求,A接口401,B接口401。刷新失败,GG

最终版

确定了同步请求顺序的不确定性的问题后。如何采用顺序请求就成了关键点。查找资料后,将这个请求先存到一个数组队列中,然后将这个请求状态置于等待中,一直等到刷新token接口置换到数据后再逐个重试清空请求队列。

为了解决这个问题,我们需要借助Promise将请求存进队列中后,同时返回一个Promise,让这个Promise一直处于Pending状态(即不调用resolve),此时这个请求就会一直处于等待中。当刷新请求的接口返回来后,我们再调用resolve,然后逐一清空请求队列。

let isRefreshing = false
let requests = []

/**
 * 获取刷新token接口后,再逐一请求队列中的接口,直到清空请求
 */
service.interceptors.response.use(
  async response => {
    let expiration = getExpiration()
    let timestamp = parseInt(new Date().getTime() / 1000)
    if (expiration && expiration - 8 * 60 <= timestamp) {
      if (!isRefreshing) {
        isRefreshing = true
        return refreshToken()
          .then(res => {
            const { token, expire } = res.data
            setToken(token)
            setExpiration(expire)
            response.headers.Authorization = `${token}`

            requests.forEach((cb) => cb(token))
            requests = [] // 重新请求完清空队列
            return service(response.config)
          })
          .catch(err => {
            removeToken()
            router.push('/login')
            return Promise.reject(err)
          })
          .finally(() => {
            isRefreshing = false
          })
      }
    }
  },
  error => {
    // ...
  }
)

经测试,完美解决无感刷新token的问题,提交测试,over

总结

刷新token,一般来说有4种方案,更推荐第四种

  1. 后端自动续期,自动置换token

我司用的就是这种,什么都不用管,真爽

  1. 定时器定时刷新
  1. 浪费资源
  2. 无法处理多接口同时请求的情况
  1. 临过期前,前端调用刷新token接口
  1. 依赖本地时间判断,如果被篡改,拦截会失败
  2. 无法处理多接口同时请求的情况
  1. 请求拦截方案 - 完美版

参考资料

  1. axios
  2. 前端如何实现token的无感刷新