Webhook 体验日志:没人要求过的破坏性变更
阅读约 1 分钟
REST API 的体验日志之所以存在,是因为调用方可以选择拒绝一个自己看不懂的响应,或者至少把错误记得足够响亮,让某个人注意到。Webhook 的接收方几乎两样都做不到。它收到一个 POST,读取自己期望的字段,如果某个字段挪动了位置、换了类型,或者干脆消失了,那个端点要么在没人盯着的后台任务里悄悄崩掉,要么更糟,带着一个从未验证过的错误值继续跑下去。什么是破坏性变更讲的是通用定义;webhook 的 payload 需要自己的一套答案,因为它失败的方式和一个被人有意调用的端点完全不同。
为什么 webhook payload 的改动,坏起来会跟 API 响应的改动不一样
因为请求的方向是反过来的。REST 的调用方发起调用,可以加一个版本头,可以在 4xx 时重试,也可以在响应里读到一条弃用提示。Webhook 的接收方对这一切都没有主动权:是你的服务器决定要发送、决定什么时候发送、决定 body 会长成什么样子。接收方唯一的手段,就是搭建集成时自己写下的那点校验逻辑,而大多数集成都是搭好一次、能跑起来,然后就没人再回头看,直到它坏掉为止。这种不对称,正是 webhook payload 的改动比调用方主动请求的响应体里的同样改动更值得谨慎对待的全部理由。
在 webhook payload 里,到底什么才算真正的破坏性变更
| 改动 | 对大多数接收方而言是否破坏性 |
|---|---|
| 新增一个字段 | 不算,前提是接收方会忽略未知字段(这个假设要验证,不能想当然) |
| 删除一个字段 | 算,只要有任何东西在读它 |
| 重命名一个字段 | 算,本质上就等于删掉了旧字段 |
| 改变字段类型(字符串变对象) | 几乎总是算 |
| 调整 JSON body 里字段的顺序 | 不算,对任何按 key 解析的接收方来说都不算(本该所有接收方都是这样) |
| 改变事件名称或类型 | 算,只要接收方据此做过滤或路由 |
“新增字段是安全的”这一行,恰恰是团队最依赖、也最值得去验证而不是想当然的一行。宽松的 JSON 解析器默认会忽略未知字段,但一个反序列化到严格 schema 的接收方,好几种带类型的语言不需要额外配置就会这么做,一旦出现意外字段就可能拒绝整个 payload。新增字段对你的 webhook 而言是否安全,取决于你是否清楚接收方是怎么解析的,而不是因为 JSON 本身天生宽容。
该怎么给 webhook payload 加上版本
和 API 响应的情况大体一样,只是多了一个细节:接收方从来不发请求,所以它没法要求某个版本,只能由发送方来声明。版本可以放在正文里,也可以放在这次投递本身的请求头里;GitHub 的投递带有 X-GitHub-Event 和 X-GitHub-Hook-ID,而 Standard Webhooks 规范则把它的元数据放在 webhook-* 请求头里。在 payload 里放一个版本字段("payload_version": 2)是最省成本的选择,只要接收方愿意据此分支处理就能用。带版本的事件类型(invoice.updated 变成 invoice.updated.v2,作为一个接收方自愿订阅的独立事件)搭建起来更费工夫,但意味着旧的形态会继续流向那些从未迁移过的人,这一点在这里比在 REST 端点上更要紧,因为你没法一个个打电话让每个接收方去更新。在注册 webhook 端点时选定的按订阅设置,把决定提前做好,而不是在每次投递时都要分支,当你手头已经有一份订阅记录可以挂靠时,这是正确的选择。
POST /receiver-endpoint
{
"event": "invoice.updated",
"payload_version": 2,
"data": { "invoice_id": "inv_123", "status": "paid" }
}
你连谁在监听都不一定知道,该怎么办
比 API 体验日志里同类问题还要棘手,因为 webhook 在你这一侧根本没有一份能叫出调用方名字的入站请求日志;你只有自己那份出站投递日志,它只能告诉你某个端点收到了 200,却不会告诉你对方拿这个 body 做了什么。至少要追踪两件事:每一个登记在案、有明确负责人的端点,这和内部 API 体验日志给内部消费者的建议是同一种纪律,以及改动 payload 之后每个端点的投递失败率。改动之后不久,某个端点的 4xx 或 5xx 响应突然飙升,是你能拿到的最接近堆栈跟踪的信号了,而且往往是唯一的信号,能告诉你某个接收方已经坏了,因为运营它的那个团队可能好几天都察觉不到。
Webhook 的体验日志该不该和 API 体验日志分开
同一页面里的一个独立板块,而不是一份独立的发布物。API 体验日志已经确立了谁会读它、怎么订阅它;webhook payload 的改动属于同一条信息流,只要标记得足够清楚,接收方那边的开发者在扫描”这会不会影响我的集成”时就能筛选出来,因为 webhook 的消费者往往没有别的理由去查一份通用的 API 体验日志,只有在有人直接把她引导过去时才会找到它。
Webhook payload 一个合理的弃用窗口该是什么样子
要比同等的 REST 弃用期更长,因为接收方那边的迁移,通常意味着一个你可能没有直接联系方式的第二个团队,得靠自己注意到这件事、规划它、在没有任何自身紧迫感的情况下把它上线。对一个接收方大概率还在用宽松库解析的字段来说,一个月是合理的下限;对一个严格 schema 会彻底拒绝的字段删除来说,三个月或更长会更安全。在可行的情况下,把旧形态和新形态在整个窗口期内一起发送(旧的 status 字段和它在版本 2 里的替代字段放在同一个 payload 里),因为读旧字段的接收方不用碰自己的代码就能继续运行,而已经迁移完的接收方只是简单地忽略掉那个自己不再需要的字段。
FAQ
Webhook 消费者需要在 payload 改动上线前确认吗? 默认根本不存在这样的确认机制,正因如此弃用窗口在这里才比在 REST API 上更重要:没有人会确认自己准备好了,所以窗口必须足够长,长到大多数接收方能按自己的节奏完成迁移,然后旧形态才消失。
什么时候添加未知字段而不通知才算安全? 只有在你验证过,而不是想当然地认为你的接收方是宽松解析的之后。一条体验日志条目成本很低,却能消除猜测;靠”JSON 解析器会忽略多余字段”这种假设悄悄加字段,会破坏任何使用严格反序列化的接收方。
payload 改动后,检测出坏掉的 webhook 接收方最快的方式是什么? 改动之后几个小时内观察到的、按端点统计的投递失败率。它不会告诉你到底坏了什么,只会告诉你有东西坏了,但这是你能拿到的最早、往往也是唯一的信号。
重试逻辑能帮助接收方挺过一次 payload 改动吗? 不能。重试只是把同一个新 payload 再发一遍,不会退回到接收方能解析的旧形态。payload 改动会在第一次投递和之后每一次重试里,用完全相同的方式弄坏接收方。
本文的技术内容未经独立审核。如有错误,请告诉我们,我们会更正。