一、一个过期令牌,打出了十二次刷新

订单页上线前最后一次弱网回归,我把 Access Token 的有效期缩成 30 秒,然后停在 OrderSyncPage 等它过期。页面刷新时会并发请求订单头、行项目、物流、优惠、发票等 12 个接口。结果十二个请求一起收到 401,响应拦截器也各自调用刷新接口:服务端日志里出现 12 个 /auth/refresh,本地 token 被写了 12 次,后返回的旧响应甚至覆盖了先返回的新令牌。

当时最迷惑的现象是“偶尔成功”。如果刷新接口按顺序完成,最后一个 token 仍可用,页面看起来正常;一旦用户在弱网中离开页面,迟到的重放请求又会写入已销毁的 ViewModel。问题并不在 Axios 会不会拦截 401,而在我们把拦截器误当成了串行入口。Demo TokenSingleFlightLab 就从这段失败日志开始,只做三件事:刷新单飞、请求代次隔离、离场批量取消。

二、先约定哪些请求有资格重放

不是所有 401 都应该刷新。刷新接口自身返回 401,说明 Refresh Token 已失效;登录、注销和匿名接口也不能进入重放。业务请求最多重放一次,否则服务端持续 401 时会形成无限递归。为此,每个 Axios 配置增加三个内部字段:skipAuthRefresh、retried、requestGeneration。它们不发给服务端,只用于本地决策。

本轮固定任务 ID 为 AUTH-0810,批量请求数 12,初始 token epoch 为 26。成功刷新后进入 epoch 27;最终指标应是 401=12、refresh=1、replayOk=10、cancelled=2、staleCommit=0,总耗时 438 ms。两次取消来自用户在物流与发票重放未完成时切换筛选范围,不计为失败。状态从 TOKEN_EXPIRED 进入 REFRESHING,随后短暂经过 REPLAYING,最后落到 HEALTHY。

三、Preferences 里保存的是版本化凭据

第一段代码解决“刷新结果以什么顺序落盘”。只存一个 token 字符串无法判断迟到写入,Demo 把令牌与 epoch 视为不可分割快照。每次刷新必须基于调用前看到的 epoch 提交;如果期间已有更新,迟到响应直接丢弃。

import { preferences } from '@kit.ArkData'

interface TokenSnapshot { accessToken: string; refreshToken: string; epoch: number }

class AuthVault {
  private prefs!: preferences.Preferences
  private current: TokenSnapshot = { accessToken: '', refreshToken: '', epoch: 0 }

  async init(context: Context): Promise<void> {
    this.prefs = await preferences.getPreferences(context, 'auth_vault')
    this.current = JSON.parse(this.prefs.getSync('snapshot',
      '{"accessToken":"","refreshToken":"","epoch":0}') as string)
  }

  snapshot(): TokenSnapshot { return { ...this.current } }

  async commitIfCurrent(baseEpoch: number, next: TokenSnapshot): Promise<boolean> {
    if (this.current.epoch !== baseEpoch || next.epoch !== baseEpoch + 1) return false
    this.current = { ...next }
    this.prefs.putSync('snapshot', JSON.stringify(this.current))
    await this.prefs.flush()
    return true
  }
}

这里的 epoch 不是服务端 token 版本,而是客户端提交序号。刷新响应必须带回调用时的 baseEpoch,并且只允许 +1,这样两个意外并行的刷新也不会互相覆盖。真实项目还应将敏感凭据放入合适的安全存储,示例用 Preferences 是为了展示状态原子性,而不是把明文持久化当成安全方案。另一个边界是 flush():只有落盘完成后才能唤醒等待重放的请求,否则进程切走时可能出现内存里是 epoch 27、磁盘仍是 26 的分叉。

四、刷新单飞不是一个布尔值

我们最早用 isRefreshing,发现 401 时设为 true,其余请求轮询等待。问题是异常路径容易忘记复位,等待者也拿不到同一个失败原因。第二段代码直接共享刷新 Promise:第一位 401 创建它,后续 401 只 await;finally 无论成功或失败都清空引用。

import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from '@ohos/axios'

class AuthHttpClient {
  private client: AxiosInstance = axios.create({ baseURL: 'https://api.example.test' })
  private refreshPromise?: Promise<TokenSnapshot>
  private refreshCount: number = 0

  install(vault: AuthVault): void {
    this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
      const token = vault.snapshot()
      config.headers.Authorization = `Bearer ${token.accessToken}`
      return config
    })

    this.client.interceptors.response.use(r => r, async (error: AxiosError) => {
      const config = error.config as RetryConfig
      if (error.response?.status !== 401 || config.skipAuthRefresh || config.retried) {
        throw error
      }
      config.retried = true
      const base = vault.snapshot()
      if (!this.refreshPromise) {
        this.refreshCount++
        this.refreshPromise = this.refresh(base).finally(() => this.refreshPromise = undefined)
      }
      const next = await this.refreshPromise
      config.headers.Authorization = `Bearer ${next.accessToken}`
      return this.client.request(config)
    })
  }
}

config.retried 必须在等待刷新前写入,不能等重放时再写,否则刷新 Promise 拒绝后某些包装层会把同一配置再次送进拦截器。刷新请求使用单独的裸客户端,并设置 skipAuthRefresh=true,避免它反过来触发自己。等待者拿到的是同一个 TokenSnapshot;如果刷新失败,十二个请求收到同一错误,统一转入重新登录,而不是各自弹一次提示。

这里还有一个经常被忽略的资源问题:拦截器是注册在实例上的。全局客户端只安装一次,不能在每次页面出现时重复 use();测试专用的临时实例则要保存 interceptor ID,并在销毁时 eject()。否则页面反复进入后,一个响应会经过多层相同拦截器,刷新计数又悄悄增加。

五、取消网络,不等于允许旧回调提交

Axios 的取消只能尽力阻止传输,无法撤回已经进入微任务队列的 then 回调。第三段代码给每次页面加载建立 RequestScope:所有请求共享页面 generation,筛选条件变化或页面销毁时同时取消;响应返回后仍要验证 generation,双保险阻止旧数据落到新页面。

class RequestScope {
  private generation: number = 0
  private sources: axios.CancelTokenSource[] = []

  begin(): number {
    this.cancelAll('superseded')
    return ++this.generation
  }

  token(): axios.CancelToken {
    const source = axios.CancelToken.source()
    this.sources.push(source)
    return source.token
  }

  assertCurrent(generation: number): void {
    if (generation !== this.generation) throw new StaleRequestError(generation)
  }

  cancelAll(reason: string): void {
    this.generation++
    this.sources.splice(0).forEach(source => source.cancel(reason))
  }
}

async function loadOrders(filter: string): Promise<void> {
  const generation = this.scope.begin()
  const calls = ORDER_ENDPOINTS.map(url => this.http.get(url, {
    params: { filter }, cancelToken: this.scope.token(), requestGeneration: generation
  }))
  const results = await Promise.allSettled(calls)
  this.scope.assertCurrent(generation)
  this.applyBatch(results)
}

begin() 先取消旧批次再递增 generation,保证新请求拿到唯一代次。Promise.allSettled() 让 10 个已完成重放能形成一次批量提交;两条被取消的请求只进入诊断计数,不触发全页失败。onPageHide 是否取消要看产品语义:本 Demo 的订单页离开即停止,所以在 aboutToDisappear 调用 cancelAll('page-disappear');若只是弹出系统选择器,就不该把短暂遮挡当成销毁。

六、我用日志反推是否真的只有一次刷新

工程目录按职责拆成 network/AuthHttpClient.ets、network/RequestScope.ets、store/AuthVault.ets、pages/OrderSyncPage.ets 和 model/AuthMetrics.ets。HiLog 标签固定为 AuthFlight。每条日志都带 task=AUTH-0810、batchGeneration=33 与 tokenEpoch,不打印 token 内容。

关键序列是:十二条 401 intercepted epoch=26,一条 refresh start baseEpoch=26,一条 refresh committed epoch=27,十条 replay ok,两条 request cancelled,最后一条 batch settled staleCommit=0 elapsed=438ms。如果看到第二条 refresh start,就算页面最终成功也算回归失败;如果 staleCommit 大于 0,说明取消只停了网络,没有守住状态提交。

DevEco Studio 右侧模拟器显示 REFRESHING → REPLAYING → HEALTHY 的收敛过程,中间代码停在共享 Promise 和 retried 标记,底部日志能对上 epoch 26 到 27。这个画面和普通“网络请求成功”截图的区别,是它能证明十二个 401 没有变成十二次刷新。

七、最终页面展示的是一次收敛结果

最终运行时,任务 AUTH-0810 的请求批次为 12,拦截到 12 个 401,刷新次数为 1;10 个重放成功,2 个被页面切换取消,旧代次提交为 0。token epoch 稳定在 27,状态为 HEALTHY,耗时 438 ms。页面同时显示“当前无等待中的刷新 Promise”,这是判断单飞锁是否释放的重要信号。

手机页没有展示令牌明文,也没有用绿色大字把它包装成营销成果。真正值得看的,是刷新次数、重放数、取消数和旧提交数之间的关系:10 + 2 = 12,且刷新只有 1。用户再次点击“模拟令牌过期”时会创建新的 batchGeneration 34,不复用上一轮的取消源或配置对象。

八、单飞能解决风暴,不能替代鉴权策略

刷新单飞只适合同一账号、同一刷新凭据的并发窗口。多账号切换时,单飞键必须至少包含 accountId;不同环境或不同 audience 的 token 也不能共用 Promise。服务端若支持 Refresh Token 轮换,客户端要把新旧 token 的提交与 epoch 一起处理,不能只替换 Access Token。

此外,401 不一定都代表过期。账号封禁、权限撤回、签名错误可能也使用 401,最好由错误码或 WWW-Authenticate 语义再做细分。离线状态下不要不断刷新;网络恢复后应先检查页面 generation 与登录态,再决定是否重放。到这里我才确定这个修复不是“给拦截器加个锁”,而是把凭据、请求和页面三条生命周期重新对齐:凭据按 epoch 提交,请求共享一次刷新,页面只接受自己的代次。三个条件同时成立,偶发成功才变成可验证的稳定结果。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐