ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HarmonyOS ArkTS + Axios:401 刷新令牌风暴的单航班合并与请求重放边界【鸿蒙心迹】

HarmonyOS ArkTS + Axios:401 刷新令牌风暴的单航班合并与请求重放边界【鸿蒙心迹】 登录态过期时页面经常不是只发一个请求。头像、购物车、订单和优惠券可能在同一帧开始加载四个接口一起收到 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 黑名单更清楚。这段代码解决拦截器无法判断原请求是否允许自动重放的问题。importaxios,{AxiosRequestConfig}fromohos/axios;exportinterfaceReplayPolicy{mode:SAFE|DENY|IDEMPOTENCY_KEY;key?:string;}exportinterfaceRelayConfigextendsAxiosRequestConfig{relay?:{requestId:string;tokenVersion:number;replayCount:number;policy:ReplayPolicy;};}exportconstapiaxios.create({baseURL:https://api.example.invalid,timeout:8000});exportfunctionsafeGet(url:string,requestId:string):Promiseunknown{constconfig:RelayConfig{url,method:GET,relay:{requestId,tokenVersion:42,replayCount:0,policy:{mode:SAFE}}};returnapi.request(config);}requestId用于串起原请求、401、等待和重放不能每次重放都换一个新 ID。tokenVersion记录请求发出时使用的令牌版本后面可以判断这个 401 是否已经被别的刷新解决。replayCount防止拦截器循环。策略为DENY时协调器即使刷新成功也只返回明确错误让业务层决定是否重新提交。示例 baseURL 使用.invalid域名明确表示代码片段不是可直接调用的线上地址。项目接入时还要在模块配置中声明网络权限并按自己的证书、域名和代理策略处理本文不把环境配置混进时序示例。三、单航班不是锁住四个请求而是共享一个 Promise所谓单航班核心不是让后来的请求轮询isRefreshing而是让它们等待同一个 Promise。第一条 401 创建刷新任务其余 401 发现任务已存在只订阅结果。Promise 完成后必须从协调器中清除下一次真正过期才能创建新任务。这段代码解决四个 401 同时触发四次刷新以及完成顺序覆盖 token 的问题。interfaceRefreshResult{taskId:string;accessToken:string;version:number;}classRefreshCoordinator{privateinFlight?:PromiseRefreshResult;privatewaiters:number0;constructor(privatetokenStore:TokenStore){}joinOrStart():PromiseRefreshResult{if(this.inFlight!undefined){this.waiters;returnthis.inFlight;}consttaskIdrefresh_20261001_09;this.inFlightthis.refreshOnce(taskId).finally((){this.inFlightundefined;this.waiters0;});returnthis.inFlight;}privateasyncrefreshOnce(taskId:string):PromiseRefreshResult{constresultawaitrequestNewToken(this.tokenStore.refreshToken);awaitthis.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 后重复刷新、无限重放和写请求误重放的问题。constcoordinatornewRefreshCoordinator(tokenStore);api.interceptors.response.use(responseresponse,asyncerror{conststatuserror.response?.status;constconfigerror.configasRelayConfig;constrelayconfig.relay;if(status!401||relayundefined||relay.replayCount0){returnPromise.reject(error);}if(relay.policy.modeDENY){returnPromise.reject(newError(AUTH_EXPIRED_REPLAY_DENIED));}constrefreshedawaitcoordinator.joinOrStart();if(refreshed.versionrelay.tokenVersion){returnPromise.reject(newError(TOKEN_VERSION_NOT_ADVANCED));}config.headers{...config.headers,Authorization:Bearer${refreshed.accessToken}};config.relay{...relay,tokenVersion:refreshed.version,replayCount:relay.replayCount1};returnapi.request(config);});replayCount 0直接拒绝第二次 401避免错误配置导致死循环。真正项目还应区分服务端返回的认证错误类型令牌过期可以恢复账号禁用、权限不足或签名无效不应该刷新。只靠 HTTP 401 一个数字做全部判断会把业务拒绝误当成会话过期。IDEMPOTENCY_KEY策略还要在重放前确认 key 已写入请求头并且服务端确实实现去重。客户端自创一个键但服务端不识别没有任何保护意义。对上传流、一次性文件句柄或已经消费的请求体也不宜直接重放协调器应把它们列为DENY交给页面重新构造。手机运行图展示的是刷新过程不展示虚构的接口耗时。四个请求同时变为WAITING状态为REFRESHING任务 ID 是refresh_20261001_09token 从v42指向v43刷新调用为 1等待者为 3。五、页面离开以后结果仍可能有效订阅却已经无效令牌刷新属于应用会话不应该因为某个页面离开就被随意取消。头像页面消失时订单页也许仍在等待同一次刷新。因此“取消网络任务”和“停止页面接收结果”要分开。页面可以为每次加载分配一个viewEpoch。离开页面时递增 epoch后续回调仍允许完成会话恢复却不能再写当前组件的State。这种做法与上一批图像超分的 generation 看起来相似但工程问题不同这里的共享刷新属于全局会话不能跟着页面资源一起释放页面只撤销自己的观察资格。这段代码解决页面离场后重放响应继续覆盖已销毁界面的问题。EntryComponentstruct SessionProbePage{Stateprivatestate:stringIDLE;Stateprivatecompleted:number0;privateviewEpoch:number0;aboutToAppear():void{constminethis.viewEpoch;this.stateREFRESHING;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.completed4;this.stateSTABLE;}).catch((){if(mine!this.viewEpoch)return;this.stateSIGNED_OUT;});}aboutToDisappear():void{this.viewEpoch;}}这里没有在aboutToDisappear()中 eject 全局拦截器。若页面每次出现都注册一次拦截器、离开又忘记注销会造成同一个 401 被多层处理。更稳妥的做法是应用启动时创建单例 client会话层持有拦截器页面只调用封装后的 API。如果项目确实动态注册拦截器就要保存use()返回的标识并在对应生命周期调用eject()。注册与注销必须成对而且不能误删其他模块的拦截器。本文选择单例方案避免页面承担这层资源管理。六、诊断页要回答“只刷新一次”之外的问题看到 refresh count 等于 1只能证明合并发生了还不能证明重放安全。诊断至少应展示原始 401 数、刷新调用数、等待峰值、旧新 token 版本、安全重放数、拒绝重放数、第二次 401 数和最终会话状态。本例最终数据为401 × 4refresh × 1waiter peak 3v42 → v43replayed 4denied 0second 401 0状态STABLE。如果把其中一个请求改成DENY预期应是replayed 3 / denied 1而不是为了让界面全绿而重放四个。日志也要形成一条可还原的链路AUTH_401 requestreq_profile tokenv42。REFRESH_START taskrefresh_20261001_09。其余三条记录REFRESH_JOIN waiter1..3。TOKEN_COMMIT oldv42 newv43。四条请求分别记录REPLAY count1。SESSION_STABLE completed4/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 的持久化失败应用当前请求可能暂时成功重启后却无法继续。项目可以采用事务式封装先准备完整新会话安全写入成功后再原子切换内存引用失败则保持旧会话不可用并要求重新登录。不要让一半新、一半旧的凭据长期共存。日志只记sessionEpoch18、tokenVersion43和结果状态不记录 token 内容。诊断时能确认“谁覆盖了谁”即可凭据本身不应该成为排查素材。崩溃上报、网络代理和截图也要检查是否意外带出 Authorization 头。十、离线与弱网会把等待队列拖得很长刷新风暴在弱网下更明显。refresh 请求迟迟不返回页面新发出的受保护请求继续加入等待队列。如果没有上限用户停留几十秒后可能积累大量等待者网络恢复时它们一起重放又形成第二次流量尖峰。协调器可以设置等待时间、队列上限和页面优先级。超过上限的新请求直接返回AUTH_RECOVERY_BUSY由界面展示重试入口后台预取可以被丢弃用户当前点击触发的请求优先保留。这里的策略不是 axios 自带能力而是项目会话层的调度规则。超时也要分两段原业务请求超时不等于 refresh 超时等待 refresh 的时间也不应无限叠加到业务请求上。每个请求应有总截止时间。刷新完成时若原请求已经过期就不要重放给页面返回REQUEST_DEADLINE_EXCEEDED比发送一个用户已经不需要的请求更诚实。网络断开时可以先等待连接恢复也可以立刻失败取决于产品场景。支付、提交类操作通常需要更明确的用户确认头像、列表等查询可以在网络恢复后重新拉取。不要把所有接口放进同一个自动恢复规则里。当应用进入后台是否继续 refresh 也要结合业务判断。会话层是全局的并不意味着任何后台时刻都要坚持完成。若系统或产品不允许继续网络任务应让刷新失败走统一出口回到前台后重新判断而不是依赖一个已经悬空的 Promise。十一、拦截器顺序会改变你看到的证据实际项目往往不止一个拦截器还有请求签名、日志、指标、业务错误映射。令牌恢复放在错误映射之后401 可能已经被改写成普通异常放在日志之前原始 401 又可能没有被记录。团队需要明确顺序和每层输入输出而不是随意追加use()。一种清晰的顺序是请求侧先补 requestId再读取当前 token 和版本随后进行签名响应侧先记录脱敏的原始状态再做认证恢复重放结果回到正常响应解析最后转换业务错误。重放请求必须沿用同一个 requestId并增加replayCount1否则链路平台会把它当成另一笔无关请求。指标也要区分“原请求耗时”和“恢复总耗时”。把等待 refresh 的时间算进接口服务耗时会误判服务端慢完全忽略又会低估用户等待。可以同时记录apiDuration、authRecoveryDuration和totalDuration但本文不虚构具体毫秒数。若第三方库升级改变拦截器行为或配置结构应先在隔离测试中验证再更新封装。业务页面不直接依赖 axios 细节升级影响就集中在TokenRelay的网络层。三方库带来的是便利不会替项目决定并发与会话语义。还要留意测试环境里的“永不过期 token”。它会让整个恢复链路长期没有被触发直到线上真实过期才暴露问题。候选版本应使用可控的短时凭据或模拟响应定期跑完四请求竞争、刷新失败和二次 401。模拟器只负责制造确定时序不能替代与真实认证服务的协议核对。如果服务端支持主动撤销测试还应覆盖“token 未到期但已被撤销”。客户端看到的仍可能是 401但此时是否允许刷新要以服务端错误码和安全策略为准。应用不应为了维持页面可用而绕过撤销决定协调器必须接受明确的不可恢复结论并收敛到SIGNED_OUT。十二、参考资料与核对说明OpenHarmony 三方库中心仓ohos/axioshttps://ohpm.openharmony.cn/ohos/axios项目说明与 API 列表https://github.com/xinthink/ohos-axiosHarmonyOS 应用开发入口https://developer.huawei.com/consumer/cn/harmonyos/develop/本文未声明完成 DevEco Studio 编译、真机网络压测或服务端联调。四张图片均为与文章字段一致的演示图不是实测证据。
返回列表