弱网下非幂等写接口(下单/支付/提交)前端超时态怎么处理:既不能盲目重发,也不能直接判「失败」——幂等键、按钮态机、pending 队列到底怎么落地?
该问题的正文、回答与讨论。
背景
社区里前端超时与重试已经讨论得不少:#493 复盘了「无限等待 + 自动重发把网关 502 打到 3.2%」的治理、#937 聊过客户端该不该做熔断与退避、#180 讨论弱网默认值;后端侧 #23 已经把「超时与重试是容量规划的一部分」讲透,#174 也聊过幂等键该放哪一层。但把这些串起来看,我观察到一个前端侧还没被正面摆上桌面的具体卡点:
当用户对一个「非幂等写接口」(下单、支付、提交表单、确认订单)点击后,请求超时(既不是 200,也不是 5xx,而是「没收到任何响应」),前端到底该怎么办?
这个问题难的地方在于:「网络超时」和「服务端已处理、但响应在回程路上丢了」是两种完全不同的语义,但前端从 TCP/HTTP 层是无法区分的。盲目重发可能重复扣款 / 重复下单;直接提示「操作失败」可能让用户以为没成功、再点一次又重复。后端侧已经有「服务端去重」(#174、#114 都是这个思路),但前端的交互态和工程兜底这一层,我没看到一篇正面回答「到底怎么做」。
我目前观察到的几种做法(都各有坑)
- 超时即 disable + 提示「请刷新查看结果」:交互保守、最安全,但用户体验断裂,且「刷新查看」依赖服务端有可查的「按幂等键查状态」接口——很多 B 端系统没做,前端只能干瞪眼。
- 本地 pending 队列 + 恢复后自动重发:交互顺,但要求服务端对幂等键做结果缓存(不只是去重),否则第二次重发还是「未命中、重新执行」,等于白做。
- 前端生成幂等键(uuid)随请求带出:标准做法,但键的生命周期没人讲清楚——用户刷新页面后键是否保留?跨 tab 是否共享?键存 localStorage 还是 sessionStorage?
- 服务端在 202 / 200 响应里返回一个「查询 token」,前端用 token 轮询结果:最干净,但要求后端改造,落地成本高。
现象(我线上遇到的)
某 B 端订单确认接口,弱网用户点「确认」后超时,前端 toast「操作失败,请重试」;用户再点一次,服务端按新 uuid 又走了一遍确认,结果库存被扣两次,客服工单爆增。事后查:请求层用的是 axios 默认无超时 + 失败后浏览器侧重试,前端根本没有「不确定态」的概念,要么成功、要么失败,没有第三种。
已尝试的排查与结果
- 给非幂等接口加 AbortController 8s 超时:解决「无限等待」,但「超时后怎么办」还是没答案;
- 按钮 disable 直到响应:解决重复点击,但用户刷新页面后状态丢失,再点就是新的 uuid;
- 想加本地 pending 队列:发现服务端只做了「同 uuid 去重」,没做「同 uuid 返回上次结果」,队列重发后还是走了一遍完整逻辑(幂等了,但结果不可查,前端还是不知道到底成没成);
- 想推动后端加「按幂等键查状态」接口:业务方排期紧张,没排上。
期望得到的答案形态(越具体越好,最好带代码片段或配置)
- 幂等键的生成与生命周期:uuid v4 还是 crypto.randomUUID()?存 sessionStorage 还是 localStorage?页面刷新后键是否应该保留?跨 tab 是否共享?有没有一个「键有效期」的合理默认值?
- 前端「不确定态」的交互态机:超时后 UI 应该停在哪个状态(「处理中」?「请查询结果」?「操作可能已生效」?),对应的按钮文案、二次点击行为、刷新恢复逻辑分别怎么设计?
- pending 队列的边界:哪些接口能进队列(非幂等写 vs 幂等读)、哪些绝对不能(支付扣款)?队列持久化到哪(IndexedDB?localStorage?)?恢复策略是「自动重发」还是「等用户确认」?
- 服务端配合的最小改造:如果只能推动后端做一件事,做哪件收益最大?「同 uuid 返回上次结果」是否足够,还是必须加「按 uuid 查状态」接口?
- 可观测性:前端怎么把「超时态」单独上报(区别于「失败态」),怎么算出「不确定率」这个指标,怎么设告警阈值?
我做了 15 年前端,习惯看落地成本与收益权衡——所以更想听小团队(< 10 人后端)能落地的最小版本,而不是「上分布式事务 + TCC + 消息最终一致性」的大方案。
欢迎贴你们的具体实现(按钮态机代码、pending 队列数据结构、服务端幂等响应样例),越具体越好。