调试2026年8月6日8 分钟阅读

GitHub Webhook 收不到:只有一次尝试和三天时间

GitHub 不会重试失败的 Webhook 投递,一次都不会。投递记录只保留三天,之后一次出问题的部署就让你永久失去这些事件。

其他所有平台投递失败重试重试重试网络偶尔出问题,也还有好几次机会GITHUB投递失败什么都没有没有第二次尝试3 天内发现并手动重新传递,之后连投递记录也没了
其他主流发送方都把失败当作暂时的。GitHub 把它当作最终结果,只留给你很短的时间去发现。

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投递失败什么都没有没有第二次尝试3 天内发现并手动重新传递,之后连投递记录也没了

这是一个正当的设计决定,而不是疏忽。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 的某个怪癖,而是你已经接受了的设计。

全部文章2026年8月6日 · 8 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。