除錯2026年8月6日閱讀時間 8 分鐘

GitHub Webhook 收不到:你只有一次嘗試和三天

GitHub 不會重試失敗的 Webhook 傳遞,一次都不會。傳遞紀錄只保存三天,之後一次出錯的部署,就讓你永久失去那些事件。

其他所有服務商傳遞失敗重試重試重試網路可以不穩好幾次GITHUB傳遞失敗什麼都沒有沒有第二次嘗試3 天內要發現並手動重新傳送,之後連傳遞紀錄也會消失
其他主要的發送端都把失敗當成暫時的。GitHub 把它當成定局,只給你一小段時間發現。

CI 沒有跑。Slack 通知一直沒發出來。你打開 GitHub 的 Recent Deliveries(最近的傳遞)分頁,事件就在那裡,旁邊一個紅色標記,時間戳記正好落在你的服務重新啟動的那九十秒裡。

接下來這點最讓人措手不及:GitHub 不會再送一次。五分鐘後不會,一小時後不會,永遠都不會。文件只用一句話,說得毫不含糊:「GitHub 不會自動重新傳送失敗的傳遞。」

如果你是從 Stripe 或 Shopify 轉過來接 GitHub Webhook 的,這和你習慣的預期完全相反。

重點摘要

  • 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:不論傳遞有沒有成功過,事件都能從 Events API 拉取 30 天。在 GitHub,傳遞紀錄本身就是那份副本。它一過期,唯一剩下的事實依據就是儲存庫本身的狀態,你只能從 commit 紀錄裡重建「18:40 到 18:42 之間發生了哪些 push」。

傳遞為什麼失敗

照順序逐項檢查。

端點花了超過 10 秒

「你的伺服器應該在收到 Webhook 傳遞後的 10 秒內,回傳 2XX 回應。」

在繁忙的 monorepo 上,一個 push 事件會帶著很大的 payload。處理程式解析它、逐一走過 commit 清單、再回頭呼叫 GitHub API 取得檔案內容,就算沒做錯任何明顯的事,也會超過十秒。請模型審查 diff 的機器人,每次 push 都會超過。GitHub 自己的建議是排入佇列:「你可能會想設定一個佇列,以非同步方式處理 Webhook payload。」逾時檢查工具會拿十秒來替你的處理程式計時,你的 Webhook 處理程式呼叫 LLM 則示範各平台上先回應、再處理的解法。

排入佇列有效。在處理程式開始執行之前就先回應也有效,這是同一個想法,只是往前移了一站。在 Cloudflare Workers 上接收 Webhook 說明了這篇提到的中繼服務怎麼做:2XX 大約一秒就從邊緣節點送出,所以那十秒花在網路上,而不是花在走訪你的 commit 上。

密鑰對不上

如果 Webhook 設定了密鑰,GitHub 會以 X-Hub-Signature-256 簽章,你的驗證必須對原始請求主體(raw body)計算。在計算雜湊之前把 JSON 重新序列化,會產生不同的位元組,驗證就會失敗並回傳 4xx,而這算作一次失敗的傳遞,不會重試。各服務商的細節見驗證 Stripe、GitHub 和 Shopify 的 Webhook 簽章。

擋在服務前面的東西拒絕了它

可能是一條 WAF 規則、一個機器人過濾器,或是預期要有 session cookie 的驗證 middleware。Webhook 流量是來自資料中心 IP 範圍的匿名 POST,正好就是大多數防護規則被調校來阻擋的樣子。GitHub 記錄到的回應會是 401、403 或 429,而不是你的程式碼回的任何東西。

沒有訂閱該事件類型

這時傳遞根本不會出現,而不是出現後失敗。檢查 Webhook 訂閱的事件清單。

是 GitHub App,而且安裝已失去存取權

對於已移除該安裝的儲存庫,GitHub App 的 Webhook 會停止傳送。傳遞紀錄分頁什麼都不顯示,看起來和「觸發條件從來沒有觸發」一模一樣。

冪等鍵

X-GitHub-Delivery 是識別事件的 GUID。有兩個特性讓它成為正確的鍵:

手動重新傳送時,它保持不變。GitHub 的說明寫得很明確:「如果你要求重新傳送,X-GitHub-Delivery 標頭會和原始傳遞時相同」。所以部署出錯後你按下 Redeliver,處理程式看到的 ID 和第一次會看到的一樣,就能認出這是已經處理過的事件。

它以事件為單位,而不是以 payload 為單位,這正是你要的。兩次真正各自獨立的 push 會產生兩個 ID,即使 payload 很相似。

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 從邊緣節點上一個只負責驗證和儲存的 Worker,大約一秒就回 200 給 GitHub,所以 10 秒的時限根本不會成為問題,變慢或失敗的處理程式,也不再會讓傳遞進入 GitHub 無法挽回的失敗狀態。

接下來:

  • 傳遞到你自己端點的請求會以指數退避重試,這正是 GitHub 沒有提供的安全網。
  • 事件連同完整的標頭和請求主體一起儲存,儲存時已加密,並在你方案的可重播期間內都能重播(Free 方案 3 天、Pro 方案 30 天、Scale 方案 90 天),而不是 GitHub 的 3 天。
  • 可以依時間範圍重播,所以週五的部署空窗,只要選取範圍再按一下,不必從 commit 歷史重建。
  • 連續失敗 1、5、20 次時會發出失敗警示,這正是 GitHub 不會發的訊號。

你的處理程式仍然必須冪等,因為手動重新傳送和重播都存在。這一點沒有任何東西能免除。

結論

你在 Stripe 上養成的除錯習慣,搬到這裡都不太管用。沒有重試可以等,沒有死信佇列可以檢查,也沒有保存三十天的 API 可以回填。只有一次嘗試、一份保存三天的成敗紀錄,然後就是一片沉默。

事件確實寫下來之後,就快速回傳 200;用 X-GitHub-Delivery 去重;在不尋常的安靜上設警示。如果在你目前的架構裡,一次失敗的傳遞是救不回來的,那不是你在繞過的 GitHub 怪癖,而是你已經接受的設計。

所有文章2026年8月6日 · 8 分鐘

別再漏掉 Webhook。

換一個網址,就有重試、事件紀錄和一鍵重播。或者先親眼看看:註冊後跑一次 30 秒的當機演練,看著一個事件在伺服器當機時保存下來。不需要伺服器。