本地 200,线上 403:一次 Spotify 排障引出的边缘鉴权一致性问题

- Published on
- /16 mins read/---
一个不播歌的组件
周日上午,我戴着耳机听着歌,顺手打开了自己的博客。
侧边栏的 Spotify「正在播放」组件安安静静地显示着:Not Playing。
可我明明在听。
这个组件的逻辑很简单:读 Spotify Web API 的 currently-playing 接口,正在听歌就显示曲目,没在听就显示 Not Playing。它死了多久了?说实话我不知道——后来查证据的时候发现,线上的环境变量已经 518 天没人动过。上一次认真看它一眼,还是一年半以前。
第一反应:refresh token 过期了。查了一下还真有依据:Spotify 在 2026 年调整了政策(官方公告:Introducing refresh token expiration),refresh token 自授权起 180 天(6 个月)过期,且刷新不续期;过期后 token 端点返回 400 invalid_grant,存量应用从 2026-07-20 开始强制执行。我那个 518 天高龄的 token,死得明明白白。
于是按部就班:重新走 OAuth 授权、更新本地 .env、同步 Vercel 三个环境的变量、重新部署。
本地一跑,立刻正常。歌名、专辑、封面,全回来了。
打开线上——还是 Not Playing。
到这里为止,一切都还只是一次普通的"token 过期换 token"。真正有意思的部分,从下一节开始。
排查:同一秒钟,两种世界
现在有两个窗口摆在我面前:
- 左边,本机 curl Spotify API:200,返回我正在听的歌;
- 右边,Vercel 生产函数(美东 iad1)调同一个接口:Not Playing。
同一套凭据——client_id、client_secret、refresh_token,逐字节一致。同一秒钟。两种结论。
遇到这种"物理学不存在了"的时刻,唯一正确的做法是承认:一定有一个假设是错的,然后一条一条排:
| 步骤 | 观察 | 结论 |
|---|---|---|
vercel env pull 拉回线上变量与本地 diff | 逐字节一致 | 排除配置漂移 |
线上函数调 token 端点(accounts.spotify.com/api/token) | 200,正常签发 access_token | 排除凭据问题——认证没有失败 |
| 那业务接口到底返回了什么? | 不知道 | 卡住了 |
卡住的原因说来惭愧,在我自己的代码里:
let response = await getNowPlaying()
if (response.status === 204 || response.status > 400) {
return Response.json({ isPlaying: false })
}写这段代码的时候想得很美:出错就优雅降级嘛,一个侧边栏小组件而已,别把页面搞挂。于是"上游拒绝了我"和"我确实没在听歌",在一切看得到的地方长得一模一样。诊断信息被我亲手吞得干干净净。
于是做了整场排障中最关键的一个动作:在目标运行环境里,把上游响应的 status 和 body 原样打出来。一行临时日志,一个 preview 部署,答案立刻浮出水面:
403: Active premium subscription required for the owner of the app.
When the subscription status changes, it can take a few hours
before requests are allowed again.
真相大白。Spotify 的平台政策要求(官方公告:Update on Developer Access and Platform Security):Development Mode 应用的 owner 必须持有有效的 Premium 订阅——订阅断掉,应用停摆;重新续订,应用恢复。
而我的 Premium 恰好断过:3 月开的季度套餐到期后一直没续,直到这天才重新续上。
续费和排障撞在同一天——这个巧合正是整场迷惑的来源。早几天续费,状态早就传播完了,我根本不会遇到问题;一直不续,403 会稳定复现,反而好查。我偏偏踩在了传播窗口的正中间。
根因:同一个 token,两种判决
把整件事串起来:
- 我的应用受"owner 必须持有有效 Premium"的政策约束;
- 我的 Premium 季包到期后断了一阵,排障当天刚续上,这个订阅状态在 Spotify 的各地区集群间按缓存副本异步同步;
- 本机的请求(中国出口)命中的集群已经收敛,看到"Premium ✓" → 200;
- Vercel 的请求(美东 iad1)命中的集群还没收敛,看到"Premium ✗" → 403。
同一个合法 token,在同一秒钟,因为命中了不同的状态副本,得到了相反的判决。
确认根因之后,我面对的是工程师最不习惯的一种修复方案:**什么都不做,等。**没有 bug 可改,没有配置可纠——根因在别人家的分布式系统里,而人家已经在错误文案里承诺了会自愈。
等待的时间正好用来想清楚一件事:这个让我白忙活半天的现象,背后其实是一个每个做系统的人都会撞上的设计问题。
插叙一:authn 状态 ≠ authz 依赖的业务状态
这个案例里最值得带走的区分是:认证(authn)全程没有失败——token 的签发和校验,两地都通过了。失败的是授权(authz)时消费的一份业务状态("owner 是否 Premium")。这两类状态的性质完全不同:
| authn 状态(token 有效性) | authz 依赖的业务状态 | |
|---|---|---|
| 例子 | JWT 签名、有效期 | 订阅等级、封禁标记、配额、租户开关 |
| 校验方式 | 无状态、密码学判定 | 有状态、查数据 |
| 一致性 | 天然全局一致(同一公钥处处同一结论) | 最终一致(副本 + 缓存 + 同步延迟) |
| 失效方式 | 到期自动失效 | 依赖失效通知 / TTL |
签名校验在世界任何角落算出来都是同一个结果;而"他是不是 Premium"必须查数据。查数据就有副本,有副本就有不一致窗口。**鉴权链路一旦引入任何"查库/查缓存"的判断,就从纯 authn 滑入了最终一致的世界。**很多系统是在滑进去很久之后才意识到的——通常是通过一个像我这样的用户的工单。
插叙二:传播窗口是有方向的
状态变更分两个方向,传播慢了的代价完全不对称:
- 授予类(刚付费、刚解封、刚开通):传播慢 → 用户做了正确的事却被拒绝。伤体验、伤收入,是用户感知最恶劣的失败模式——"我刚付了钱,你跟我说我不是会员?"本案例就是这一类;
- 撤销类(封号、退订、吊销):传播慢 → 已失权的主体多用了一个窗口期。伤安全、伤计费。
所以两个方向不该共用一个 TTL:撤销要快(安全优先),授予可以稍慢——但要有兜底。
最直接的兜底叫 verify-on-deny:基于缓存状态要返回拒绝(403/402)之前,先同步回源做一次权威确认,确认仍拒绝才拒绝。这笔账很划算:拒绝是低频路径,回源代价可以忽略;放行是高频路径,继续走缓存。一次回源,就能消灭"刚付费却被拒"这一整类问题。
Spotify 显然没做这个——不然也不用在错误文案里写"可能要等几个小时"了。
插叙三:geo 路由会把不一致暴露给同一个用户
单机房时代,状态副本不一致只是内部实现细节,用户看不见。一旦入口做了地理调度(geo-DNS / anycast → 多 POP → 多集群),同一个用户换个出口 IP,就可能命中不同副本。
我这次的"本机 vs Vercel"恰好构成了一组天然的双观测点(vantage point)实验:两个出口 IP,两个地区集群,两种判决。反过来,这也是个值得记住的排障技巧——怀疑状态不一致时,主动制造多观测点:不同地区的机器、云函数各调一次,判决不同即是铁证。
彩蛋:从响应头读出 Spotify 的入口拓扑
排障过程中顺手看了眼 Spotify API 的响应头,结果多挖出一层乐趣:
via: HTTP/1.1 fringe, HTTP/2 edgeproxy, 1.1 google
server: envoy
两行头,能读出多少东西?关键是分清哪些结论可信、哪些只是推断:
| 结论 | 依据 | 置信度 |
|---|---|---|
| 链路顺序 client → google → edgeproxy → fringe → 源站 | RFC 9110 §7.6.3:每个代理把自己追加到 Via 末尾,响应方向上越靠前离源越近 | 高(标准保证) |
1.1 google 是 Google Cloud 负载均衡(GFE) | Google 官方文档写明的签名 | 高(有文档) |
| fringe / edgeproxy 是 Spotify 自建组件 | 不是任何已知公共产品的签名,且位于 Google LB 内侧 | 中(推断) |
| 链路里存在 Envoy | server: envoy 是 Envoy 的默认行为,且外层没有覆盖它 | 中(存在可信,位置不可证) |
| 完整拓扑 | —— | 不可知:RFC 允许代理不追加自己,Via 只是下界 |
两点提醒:Via 里的节点名是运营方自配的化名,标准并不验证;Server 头可以被任何一跳改写。所以读 Via 头是"考古"而不是"测绘"——顺序可信,命名和完整性都不可信。
顺带一提:如果你在建自己的网关,出口最好统一剥掉或归一化内部跳的 Via、改写 Server。fringe、edgeproxy 这类内部命名泄漏虽属低危,但没必要白送。
Takeaways:给系统设计者的六条规则
这次排障的对象是别人家的系统,我改不了他们一行代码。但下面六条规则,适用于每一个做鉴权、做网关、做多地部署的系统——包括你现在正在维护的那个:
- 显式标注每份状态依赖。鉴权链路里每一份被消费的状态(订阅、封禁、配额……),设计时写清楚:数据源、副本分布、TTL、失效机制、可容忍的不一致窗口。没标注的状态,默认按"会不一致"对待。
- 授予与撤销分别定传播 SLO,不共用一个 TTL。撤销从严(秒到分钟级),授予可以放宽,但要有兜底。
- verify-on-deny。基于缓存状态返回拒绝之前,回源权威确认一次。拒绝是低频路径,这笔开销买断了"刚付费却被拒"整类问题。
- 不吞上游的拒绝原因。对外响应可以降级(我的组件降级成 Not Playing 没有问题),但日志必须落上游 status + 错误摘要,降级响应与真实空态要在日志层可区分。我这次排障多花的每一分钟,都是在为当初随手吞掉的那个 403 还债。
- 拒绝响应要带解释。机器可读的 reason code + 人类可读的原因;涉及状态同步的,写明预期收敛时间。Spotify 那句 "it can take a few hours" 一句话终结了整场排障——一句好的错误文案,能替全世界的用户省下无数个下午。
- 怀疑不一致时,制造多观测点。不同地区各调一次,判决不同即是铁证。
尾声
根因确认,能做的都做完了。我合上电脑,洗了个澡,出门买菜,回来做了顿饭。

端着饭坐回电脑前,大概下午一点,博客右下角重新滚动起了曲目名——美东集群收敛了。在我洗菜还是炒菜的某个瞬间,大洋彼岸某台缓存服务器终于承认:这个人是 Premium。
恢复后我点开一看,正在播的那首歌叫 《You Will Find Me》。
行,算你应景。
这次排障没有改一行"修复代码"——根因在别人家的系统里,等待就是正确答案。但它留下了三样东西:一个应对 180 天过期的一键重授权脚本,这篇文章,和一句我以后会反复用到的话:
认证是密码学问题,授权是数据一致性问题。
下次你的系统在两个地方给出两种答案时,先别怀疑网络。问问它依赖的那份状态——此刻在哪个副本上,长什么样。
