登录态过期时,页面经常不是只发一个请求。头像、购物车、订单和优惠券可能在同一帧开始加载,四个接口一起收到 401。如果每个响应拦截器都去刷新令牌,服务端看到的不是一次恢复,而是一场刷新风暴;如果四个请求都自动重放,写接口又可能被执行两次。

本文用示例工程 TokenRelay 拆这段时序。页面为 SessionProbePage,演示时间统一为 13:18,本轮刷新任务 refresh_20261001_09,旧令牌版本 v42,新版本 v43,并发请求 4 个,真正的刷新调用 1 次,等待队列峰值 3,最终安全重放 4 个。所有数据用于文图一致和逻辑演示,不冒充网络实测。

这里采用 ohpm 中的 @ohos/axios。其项目说明明确保留 Promise、请求/响应拦截器等 Axios 常见用法。文章只使用这些可核对能力;TokenStore、RefreshCoordinator 和重放策略都是 TokenRelay 的项目代码,不把它们包装成 HarmonyOS 系统接口。

一、四个 401 并不是四次刷新许可

先看一个容易写出的版本:响应拦截器收到 401,就调用 /auth/refresh,拿到新 token 后重新发原请求。单接口测试大概率能过,多接口并发时却会出现三种结果。

第一种,四个刷新请求都带着同一个旧 refresh token 到达服务端。若服务端采用一次性轮换,只有最先完成的请求成功,后面三个会被判定为失效。页面刚拿到 v43,又被后续失败分支清空登录态。

第二种,服务端容忍并发刷新,四个响应分别返回不同 token。完成顺序与发起顺序无关,较早生成的 token 可能最后写入本地,把新状态覆盖成旧状态。

第三种,开发者为了“保证恢复”,对所有失败请求直接重放。GET 查询通常可接受,订单提交、领取优惠券等写操作若没有幂等键,就可能产生重复副作用。401 只说明认证失败,不等于原请求肯定没有被服务端处理。

所以请求恢复至少要分成三层:刷新令牌只能单航班;等待者共享同一个刷新结果;是否重放由请求自身策略决定。它们不能都塞进一个 isRefreshing 布尔值里。

示例状态机为:

IDLE → REFRESHING → REPLAYING → STABLE

刷新失败则进入 SIGNED_OUT。任何等待者都不能在刷新失败后继续偷偷重试,避免页面一边跳登录,一边还有旧请求修改状态。

二、给请求带上可审计的恢复策略

拦截器只能看到配置和响应。如果配置里没有说明请求能否重放,它就只能靠 method 猜。GET 通常是幂等的,但也存在带副作用的查询;POST 通常有副作用,但若携带服务端支持的幂等键,也可能安全重试。把规则显式放进请求元数据,比在拦截器里维护 URL 黑名单更清楚。

这段代码解决拦截器无法判断原请求是否允许自动重放的问题。

import axios, { AxiosRequestConfig } from '@ohos/axios';

export interface ReplayPolicy {
  mode: 'SAFE' | 'DENY' | 'IDEMPOTENCY_KEY';
  key?: string;
}

export interface RelayConfig extends AxiosRequestConfig {
  relay?: {
    requestId: string;
    tokenVersion: number;
    replayCount: number;
    policy: ReplayPolicy;
  };
}

export const api = axios.create({
  baseURL: 'https://api.example.invalid',
  timeout: 8000
});

export function safeGet(url: string, requestId: string): Promise<unknown> {
  const config: RelayConfig = {
    url,
    method: 'GET',
    relay: {
      requestId,
      tokenVersion: 42,
      replayCount: 0,
      policy: { mode: 'SAFE' }
    }
  };
  return api.request(config);
}

requestId 用于串起原请求、401、等待和重放,不能每次重放都换一个新 ID。tokenVersion 记录请求发出时使用的令牌版本,后面可以判断这个 401 是否已经被别的刷新解决。replayCount 防止拦截器循环。策略为 DENY 时,协调器即使刷新成功也只返回明确错误,让业务层决定是否重新提交。

示例 baseURL 使用 .invalid 域名,明确表示代码片段不是可直接调用的线上地址。项目接入时还要在模块配置中声明网络权限,并按自己的证书、域名和代理策略处理;本文不把环境配置混进时序示例。

三、单航班不是锁住四个请求,而是共享一个 Promise

所谓单航班,核心不是让后来的请求轮询 isRefreshing,而是让它们等待同一个 Promise。第一条 401 创建刷新任务,其余 401 发现任务已存在,只订阅结果。Promise 完成后必须从协调器中清除,下一次真正过期才能创建新任务。

这段代码解决四个 401 同时触发四次刷新,以及完成顺序覆盖 token 的问题。

interface RefreshResult {
  taskId: string;
  accessToken: string;
  version: number;
}

class RefreshCoordinator {
  private inFlight?: Promise<RefreshResult>;
  private waiters: number = 0;

  constructor(private tokenStore: TokenStore) {}

  joinOrStart(): Promise<RefreshResult> {
    if (this.inFlight !== undefined) {
      this.waiters++;
      return this.inFlight;
    }

    const taskId = 'refresh_20261001_09';
    this.inFlight = this.refreshOnce(taskId)
      .finally(() => {
        this.inFlight = undefined;
        this.waiters = 0;
      });
    return this.inFlight;
  }

  private async refreshOnce(taskId: string): Promise<RefreshResult> {
    const result = await requestNewToken(this.tokenStore.refreshToken);
    await this.tokenStore.commitIfNewer(result.accessToken, 43);
    return { taskId, accessToken: result.accessToken, version: 43 };
  }
}

四个并发请求中,第一条进入 refreshOnce(),后面三条只增加等待计数,所以配图显示“刷新调用 1、等待峰值 3”。finally 清理的是协调器对 Promise 的引用,不清理已经提交的新 token。若刷新失败,所有等待者收到同一个 rejection,再由统一会话层进入 SIGNED_OUT。

commitIfNewer() 仍然有必要。单航班只约束当前进程中的这个协调器;应用可能存在多个网络实例,或者某个历史实现仍在运行。令牌仓储按版本拒绝旧值,可以再挡一次倒序写入。版本应来自可信的会话协议或本地单调序列,不能用设备时间冒充。

另一个细节是 refresh 请求本身不能再次进入普通 401 拦截器。可以为刷新实例单独创建一个不挂恢复拦截器的 axios client,或者在配置中标记 skipAuthRecovery。否则刷新接口返回 401 时会等待自己产生的 Promise,形成自锁。

下面的 DevEco Studio 风格画面是演示图,不是实际 IDE 截图。左侧目录、中间 joinOrStart()、右侧请求探针和底部 HiLog 共用本篇字段:v42 → v43、4 个 401、1 次 refresh、3 个 waiter。

四、响应拦截器只做分流,不承担全部会话逻辑

拦截器的职责是识别“可恢复的认证失败”,把请求交给协调器,并在得到新 token 后执行受控重放。它不应该弹登录框、不应该直接改页面状态,也不应该无限重试。

这段代码解决 401 后重复刷新、无限重放和写请求误重放的问题。

const coordinator = new RefreshCoordinator(tokenStore);

api.interceptors.response.use(
  response => response,
  async error => {
    const status = error.response?.status;
    const config = error.config as RelayConfig;
    const relay = config.relay;

    if (status !== 401 || relay === undefined || relay.replayCount > 0) {
      return Promise.reject(error);
    }
    if (relay.policy.mode === 'DENY') {
      return Promise.reject(new Error('AUTH_EXPIRED_REPLAY_DENIED'));
    }

    const refreshed = await coordinator.joinOrStart();
    if (refreshed.version <= relay.tokenVersion) {
      return Promise.reject(new Error('TOKEN_VERSION_NOT_ADVANCED'));
    }

    config.headers = {
      ...config.headers,
      Authorization: `Bearer ${refreshed.accessToken}`
    };
    config.relay = {
      ...relay,
      tokenVersion: refreshed.version,
      replayCount: relay.replayCount + 1
    };
    return api.request(config);
  }
);

replayCount > 0 直接拒绝第二次 401,避免错误配置导致死循环。真正项目还应区分服务端返回的认证错误类型:令牌过期可以恢复,账号禁用、权限不足或签名无效不应该刷新。只靠 HTTP 401 一个数字做全部判断,会把业务拒绝误当成会话过期。

IDEMPOTENCY_KEY 策略还要在重放前确认 key 已写入请求头,并且服务端确实实现去重。客户端自创一个键但服务端不识别,没有任何保护意义。对上传流、一次性文件句柄或已经消费的请求体,也不宜直接重放;协调器应把它们列为 DENY,交给页面重新构造。

手机运行图展示的是刷新过程,不展示虚构的接口耗时。四个请求同时变为 WAITING,状态为 REFRESHING,任务 ID 是 refresh_20261001_09,token 从 v42 指向 v43,刷新调用为 1,等待者为 3。

五、页面离开以后,结果仍可能有效,订阅却已经无效

令牌刷新属于应用会话,不应该因为某个页面离开就被随意取消。头像页面消失时,订单页也许仍在等待同一次刷新。因此“取消网络任务”和“停止页面接收结果”要分开。

页面可以为每次加载分配一个 viewEpoch。离开页面时递增 epoch,后续回调仍允许完成会话恢复,却不能再写当前组件的 @State。这种做法与上一批图像超分的 generation 看起来相似,但工程问题不同:这里的共享刷新属于全局会话,不能跟着页面资源一起释放;页面只撤销自己的观察资格。

这段代码解决页面离场后重放响应继续覆盖已销毁界面的问题。

@Entry
@Component
struct SessionProbePage {
  @State private state: string = 'IDLE';
  @State private completed: number = 0;
  private viewEpoch: number = 0;

  aboutToAppear(): void {
    const mine = ++this.viewEpoch;
    this.state = 'REFRESHING';
    Promise.all([
      safeGet('/v1/profile', 'req_profile'),
      safeGet('/v1/orders', 'req_orders'),
      safeGet('/v1/cart', 'req_cart'),
      safeGet('/v1/coupons', 'req_coupons')
    ]).then(() => {
      if (mine !== this.viewEpoch) return;
      this.completed = 4;
      this.state = 'STABLE';
    }).catch(() => {
      if (mine !== this.viewEpoch) return;
      this.state = 'SIGNED_OUT';
    });
  }

  aboutToDisappear(): void {
    ++this.viewEpoch;
  }
}

这里没有在 aboutToDisappear() 中 eject 全局拦截器。若页面每次出现都注册一次拦截器、离开又忘记注销,会造成同一个 401 被多层处理。更稳妥的做法是应用启动时创建单例 client,会话层持有拦截器;页面只调用封装后的 API。

如果项目确实动态注册拦截器,就要保存 use() 返回的标识,并在对应生命周期调用 eject()。注册与注销必须成对,而且不能误删其他模块的拦截器。本文选择单例方案,避免页面承担这层资源管理。

六、诊断页要回答“只刷新一次”之外的问题

看到 refresh count 等于 1,只能证明合并发生了,还不能证明重放安全。诊断至少应展示:原始 401 数、刷新调用数、等待峰值、旧新 token 版本、安全重放数、拒绝重放数、第二次 401 数和最终会话状态。

本例最终数据为:401 × 4,refresh × 1,waiter peak 3,v42 → v43,replayed 4,denied 0,second 401 0,状态 STABLE。如果把其中一个请求改成 DENY,预期应是 replayed 3 / denied 1,而不是为了让界面全绿而重放四个。

日志也要形成一条可还原的链路:

  1. AUTH_401 request=req_profile token=v42。
  2. REFRESH_START task=refresh_20261001_09。
  3. 其余三条记录 REFRESH_JOIN waiter=1..3。
  4. TOKEN_COMMIT old=v42 new=v43。
  5. 四条请求分别记录 REPLAY count=1。
  6. SESSION_STABLE completed=4/4。

日志中不要写完整 access token、refresh token、Cookie 或用户敏感参数。版本号足以说明先后关系,taskId 和 requestId 足以关联事件。若需要区分账号,应使用不可逆或脱敏标识,并遵循项目的日志保留规则。

七、刷新失败比刷新成功更需要统一出口

刷新接口可能超时、返回 401、返回格式错误,也可能成功拿到 token 但本地安全存储写入失败。它们不能由四个等待请求各自弹一次提示。协调器应把失败规范成会话事件,清理不可再用的认证状态,再由应用级导航决定是否进入登录页。

清理也有顺序。先把会话标记为不可用,阻止新请求继续带旧 token;再清理 access token 和需要废弃的 refresh token;最后通知界面。若先跳转登录页,后台队列仍可能继续发送旧请求,日志里会出现一串无意义的 401。

刷新成功但重放失败,则不一定要退出登录。网络中断、服务端 500、参数错误都与会话不同。拦截器完成 token 更新后,普通错误应回到业务请求自己的失败处理。把“登录恢复成功”和“接口业务成功”混成一个状态,会让页面给出错误提示。

还有多账号切换边界。刷新开始后用户主动退出并登录另一个账号,旧刷新结果绝不能提交到新会话。TokenStore 除版本外还应比较 sessionId;账号切换时递增会话 epoch。本文为聚焦 401 风暴没有展开完整实现,但上线前必须纳入测试。

八、验收应该制造竞争,而不是顺序点四个按钮

要验证单航班,需要让四个 401 的响应窗口重叠。可以在测试环境控制服务端返回,或者给模拟适配器设置屏障,在四个请求都进入拦截器后才释放 refresh。测试关注的是事件数量与顺序,不需要伪造某台设备的性能数字。

建议覆盖这些组合:四个安全 GET 同时 401;安全 GET 与禁止重放的写请求混合;refresh 自身返回 401;刷新成功后一个重放再次 401;页面在刷新中离开;账号在刷新中主动退出;两个 axios 实例误建两个协调器;token 提交失败。

每个用例都核对不变量:同一会话 epoch 只有一个刷新 Promise;每个原请求最多重放一次;禁止重放的请求不会进入 api.request(config);页面离开后不更新组件;日志不含凭据;刷新失败只有一个统一会话出口。

最后的判断很简单:401 恢复不是“拦截器里再请求一次”,而是一套并发协议。单航班解决刷新数量,版本提交解决倒序覆盖,重放策略解决副作用,页面 epoch 解决观察者失效。四层各自回答一个问题,代码才不会在登录过期那一刻同时失控。

九、TokenStore 不只是两个字符串的容器

很多示例把 access token 和 refresh token 放进普通全局变量,刷新后直接赋值。这种写法无法回答三个问题:当前令牌属于哪个账号、哪个结果更新、应用重启后如何恢复。TokenStore 至少要同时维护 sessionId、tokenVersion、access token、refresh token 和会话状态。

提交新令牌时,先比较 sessionId,再比较版本。刷新任务开始时捕获的是会话 A;如果用户在等待期间退出并登录会话 B,即使 A 的刷新结果版本更大,也不能写入 B。版本只在同一 session 内有意义,不能跨账号比较。

令牌持久化也不应与普通偏好设置混为一谈。具体存储能力要根据当前安全方案选择,并核对敏感数据保护要求。本文不为方便演示而给出一段把 refresh token 明文写进 Preferences 的代码。无论采用何种存储,读取失败、写入失败和清除失败都要变成会话事件,不能静默回退到空字符串。

写入顺序同样重要。若新 access token 已进入内存,而 refresh token 的持久化失败,应用当前请求可能暂时成功,重启后却无法继续。项目可以采用事务式封装:先准备完整新会话,安全写入成功后再原子切换内存引用;失败则保持旧会话不可用并要求重新登录。不要让一半新、一半旧的凭据长期共存。

日志只记 sessionEpoch=18、tokenVersion=43 和结果状态,不记录 token 内容。诊断时能确认“谁覆盖了谁”即可,凭据本身不应该成为排查素材。崩溃上报、网络代理和截图也要检查是否意外带出 Authorization 头。

十、离线与弱网会把等待队列拖得很长

刷新风暴在弱网下更明显。refresh 请求迟迟不返回,页面新发出的受保护请求继续加入等待队列。如果没有上限,用户停留几十秒后可能积累大量等待者;网络恢复时它们一起重放,又形成第二次流量尖峰。

协调器可以设置等待时间、队列上限和页面优先级。超过上限的新请求直接返回 AUTH_RECOVERY_BUSY,由界面展示重试入口;后台预取可以被丢弃,用户当前点击触发的请求优先保留。这里的策略不是 axios 自带能力,而是项目会话层的调度规则。

超时也要分两段:原业务请求超时不等于 refresh 超时,等待 refresh 的时间也不应无限叠加到业务请求上。每个请求应有总截止时间。刷新完成时若原请求已经过期,就不要重放;给页面返回 REQUEST_DEADLINE_EXCEEDED 比发送一个用户已经不需要的请求更诚实。

网络断开时可以先等待连接恢复,也可以立刻失败,取决于产品场景。支付、提交类操作通常需要更明确的用户确认;头像、列表等查询可以在网络恢复后重新拉取。不要把所有接口放进同一个自动恢复规则里。

当应用进入后台,是否继续 refresh 也要结合业务判断。会话层是全局的,并不意味着任何后台时刻都要坚持完成。若系统或产品不允许继续网络任务,应让刷新失败走统一出口;回到前台后重新判断,而不是依赖一个已经悬空的 Promise。

十一、拦截器顺序会改变你看到的证据

实际项目往往不止一个拦截器:还有请求签名、日志、指标、业务错误映射。令牌恢复放在错误映射之后,401 可能已经被改写成普通异常;放在日志之前,原始 401 又可能没有被记录。团队需要明确顺序和每层输入输出,而不是随意追加 use()。

一种清晰的顺序是:请求侧先补 requestId,再读取当前 token 和版本,随后进行签名;响应侧先记录脱敏的原始状态,再做认证恢复,重放结果回到正常响应解析,最后转换业务错误。重放请求必须沿用同一个 requestId,并增加 replayCount=1,否则链路平台会把它当成另一笔无关请求。

指标也要区分“原请求耗时”和“恢复总耗时”。把等待 refresh 的时间算进接口服务耗时,会误判服务端慢;完全忽略又会低估用户等待。可以同时记录 apiDuration、authRecoveryDuration 和 totalDuration,但本文不虚构具体毫秒数。

若第三方库升级改变拦截器行为或配置结构,应先在隔离测试中验证,再更新封装。业务页面不直接依赖 axios 细节,升级影响就集中在 TokenRelay 的网络层。三方库带来的是便利,不会替项目决定并发与会话语义。

还要留意测试环境里的“永不过期 token”。它会让整个恢复链路长期没有被触发,直到线上真实过期才暴露问题。候选版本应使用可控的短时凭据或模拟响应,定期跑完四请求竞争、刷新失败和二次 401。模拟器只负责制造确定时序,不能替代与真实认证服务的协议核对。

如果服务端支持主动撤销,测试还应覆盖“token 未到期但已被撤销”。客户端看到的仍可能是 401,但此时是否允许刷新要以服务端错误码和安全策略为准。应用不应为了维持页面可用而绕过撤销决定;协调器必须接受明确的不可恢复结论并收敛到 SIGNED_OUT。

十二、参考资料与核对说明

本文未声明完成 DevEco Studio 编译、真机网络压测或服务端联调。四张图片均为与文章字段一致的演示图,不是实测证据。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐