CI 没有跑。Slack 通知也一直没发出来。你打开 GitHub 的 Recent Deliveries(最近交付)标签页,事件就在那里,旁边一个红色标记,时间戳正好落在你的服务重启的那九十秒里。
接下来这部分最容易让人中招。GitHub 不会再发一次。五分钟后不会,一小时后不会,永远都不会。文档只用了一句话,没有任何含糊:“GitHub 不会自动重新传递失败的投递。”
如果你是从 Stripe 或 Shopify 转到 GitHub Webhook 的,这和你习惯的预期正好相反。
TL;DR
- GitHub 给你的端点 10 秒时间返回 2XX
- 没有自动重试。除非有人介入,一次失败的投递就是一个丢失的事件
- 投递记录只保留 3 天。过了这个期限,你连漏掉了什么都看不到
X-GitHub-Delivery在手动重新传递时保持不变,所以它才是正确的幂等键- 因为没有安全网,修复办法只能是“从一开始就不失败”,也就是先持久化,再处理
其他平台怎么做,GitHub 怎么做
Stripe 用指数退避重试三天。Shopify 在四小时内重试八次。Slack 重试三次。Paddle 在大约两天内最多尝试六十次。这些发送方都假定你的端点有时会不可用,并围绕这一点做设计;具体数字汇总在我们的 Webhook 平台参考里。
GitHub 发出请求。如果 10 秒内没有收到 2XX,它就记下这次失败,然后继续往下走。
这是一个正当的设计决定,而不是疏忽。GitHub 投递的事件数量极其庞大,其中大部分只是通知性质的,在这种规模上维护重试队列代价很高。但它把可靠性的全部负担转给了你,而且不会给你任何提示,因为配置流程里没有任何地方告诉你,这个平台的行为和其他平台不一样。
真正的约束是三天的期限
失败的投递可以在 Settings → Webhooks → Recent Deliveries 中看到,你可以对其中任何一条点击 Redeliver(重新传递)。同样的操作也有 REST API,适用于仓库、组织和 GitHub App 的 Webhook。
限制在于:“你可以重新传递过去 3 天内发生的 Webhook 投递。”
三天听起来挺宽裕,直到你把它放到团队实际发现问题的过程里看。一个 Webhook 在周五晚上的部署中失败了。没人去看投递记录,因为没有任何告警。周一早上,有人问为什么某个 PR 一直没被打上标签。到了周二,投递记录已经过期,你连丢了哪些都列不出来,更别说重新传递了。
对比一下 Stripe:无论投递是否成功过,事件在 30 天内都可以通过 Events API 拉取。而在 GitHub,投递记录就是那份副本。记录一过期,剩下唯一可信的来源就是仓库本身的状态,你只能从提交日志里还原“18:40 到 18:42 之间发生了哪些推送”。
投递为什么失败
按顺序逐项排查。
端点响应超过了 10 秒
“你的服务器应在收到 Webhook 投递后 10 秒内返回 2XX 响应。”
在繁忙的 monorepo 上,一个 push 事件带着很大的请求体;处理程序解析它、遍历提交列表、再回调 GitHub API 获取文件内容,即使没做任何明显的错事,也会超过十秒。一个让模型审查 diff 的机器人,每次推送都会超时。GitHub 自己的建议是用队列:“你可能需要设置一个队列来异步处理 Webhook 请求体。”超时检测工具会测出你的处理程序用时,和这十秒对照;你的 Webhook 处理程序调用了大模型(LLM)给出了每个平台上“先应答”的修复办法。
队列可行。在处理程序运行之前就先应答,同样可行,这是把同一个思路往前挪了一跳。在 Cloudflare Workers 上接收 Webhook 讲的就是本文中这个转发服务的做法:2XX 约 1 秒就从边缘节点发出,十秒的时限花在网络上,而不是花在遍历你的提交上。
密钥不匹配
如果 Webhook 配置了密钥,GitHub 会用 X-Hub-Signature-256 签名,你的验证必须基于原始请求体。在计算哈希之前重新序列化 JSON,得到的字节就不一样,验证失败,返回 4xx,而这算作一次失败的投递,不会重试。各平台的细节见验证 Stripe、GitHub 和 Shopify 的 Webhook 签名。
服务前面的某一层拒绝了请求
WAF 规则、机器人过滤,或者要求会话 Cookie 的鉴权中间件。Webhook 流量是来自数据中心 IP 段的匿名 POST 请求,而大多数防护规则要拦截的正是这种形态的流量。GitHub 记录下的响应会是 401、403 或 429,而不是你的代码返回的任何东西。
没有订阅这个事件类型
这种情况下投递根本不会出现,而不是出现后失败。检查 Webhook 订阅的事件列表。
这是一个 GitHub App,而安装失去了访问权限
对于已从安装中移除的仓库,GitHub App 的 Webhook 会停止发送。Recent Deliveries 标签页里什么都没有,看起来和“触发器从来没触发”完全一样。
幂等键
X-GitHub-Delivery 是标识事件的 GUID。有两个特性让它成为正确的键:
它在手动重新传递时保持不变。GitHub 的指南写得很明确:“如果你请求重新传递,X-GitHub-Delivery 请求头会与原始投递中的相同”,所以在一次出问题的部署之后点击 Redeliver,你的处理程序看到的 ID 和第一次会看到的一样,能认出这是一个已经处理过的事件。
它按事件区分,而不是按请求体区分,这正是你需要的。两次真正独立的推送会产生两个 ID,即使请求体很相似。
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyGitHubSignature(raw, req.headers.get("x-hub-signature-256"))) {
return new Response("unauthorized", { status: 401 });
}
const deliveryId = req.headers.get("x-github-delivery");
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'github') ON CONFLICT (event_id) DO NOTHING`,
[deliveryId],
);
if (rowCount === 0) return new Response(null, { status: 200 });
await queue.publish({ deliveryId, event: req.headers.get("x-github-event"), body: raw });
return new Response(null, { status: 200 });
}
遇到重复时返回 200,而不是报错。更完整的模式,包括标记写入和副作用无法放进同一个事务时该怎么办,见同一个 Webhook 收到了两次。
为不重试的发送方做设计
对其他平台,你在可用性上可以稍微随意一点,因为重试计划会兜住。这里的设计约束不同:你唯一能保证收到的投递就是第一次,所以第一次必须成功。
这意味着处理程序在返回 200 之前只做最少的工作。验证签名,把原始请求体写到持久化的地方,返回。其余一切,包括最可能变慢或抛错的部分,都放在响应之后进行,可以按自己的计划重试,不需要 GitHub 盯着。
这也意味着你需要在三天期限内得知失败,而不是等到有人偶然发现。GitHub 不会告诉你。没有失败邮件,没有端点被禁用的通知,也没有任何类似 Shopify 的警告或 Stripe 的停用通知的东西。如果唯一能暴露缺口的,是有人打开投递记录标签页,那你拥有的不是监控,而是运气。
有一个零成本又有用的检查:一个本应定期产生事件的仓库,如果安静的时间超过了平常的间隔,就发出告警。这能同时抓到“我们的端点在出错”和“Webhook 在一次设置清理中被删了”,而后者是这类问题常见得出奇的一种结局。
AnyHook 能做什么
结构上的问题在于:GitHub 手里事件的唯一副本,是一份三天后就会过期的投递记录;而你手里的唯一副本,是处理程序在崩溃之前来得及写下的那些。
把 Webhook 指向 in.anyhook.net/you/github,副本就换了地方。AnyHook 约 1 秒就向 GitHub 返回 200,由边缘节点上一个只负责验证和持久化的 Worker 返回,所以 10 秒的时限根本不会成为问题,慢或出错的处理程序也再不会让这次投递进入 GitHub 无法恢复的失败状态。
之后:
- 投递到你自己端点的请求按指数退避重试,这正是 GitHub 不提供的安全网
- 事件连同完整的请求头和请求体一起保存,做了静态加密,并在你套餐的重放窗口内都可以重放(Free 3 天,Pro 30 天,Scale 90 天),而不是 GitHub 的 3 天
- 可以按时间范围重放,所以周五那段部署窗口只需选中再点一下,不用从提交历史里还原
- 连续失败 1 次、5 次和 20 次时会触发失败告警,这正是 GitHub 不发的信号
你的处理程序仍然必须幂等,因为手动重新传递和重放都存在。这一点什么都替代不了。
总结
你在 Stripe 上养成的排查习惯,在这里大多用不上。没有重试可等,没有死信队列可查,也没有保留三十天的 API 可以回填。只有一次尝试,一份保留三天的成败记录,然后是一片沉默。
先写下来,再快速返回 200;按 X-GitHub-Delivery 去重;为异常的安静设置告警。如果在你现在的架构里,一次失败的投递无法恢复,那不是你在绕开 GitHub 的某个怪癖,而是你已经接受了的设计。