CI が実行されませんでした。Slack への通知も投稿されませんでした。GitHub の Recent Deliveries(最近の配信)タブを開くと、そのイベントは赤い印付きでそこにあります。タイムスタンプは、アプリが再起動していた 90 秒の間を指しています。
ここからが、多くの人が引っかかる部分です。GitHub がそのイベントを送り直すことはありません。5 分後も、1 時間後も、この先ずっとです。ドキュメントにも、言い切りの 1 文で「GitHub では、失敗した配信は自動的に再配信されません」と書かれています。
Stripe や Shopify の Webhook に慣れていると、これは身についた予想とは正反対です。
TL;DR
- エンドポイントが 2XX を返すまでに GitHub が待つのは 10 秒です
- 自動のリトライはありません。人が手を打たない限り、失敗した配信はそのまま失われたイベントになります
- 配信記録が残るのは 3 日間です。それを過ぎると、何を取りこぼしたのかさえ見えません
X-GitHub-Deliveryは手動で再送しても変わらないので、冪等キーとして正しい選択です- セーフティネットがないので、対策は「そもそも失敗しない」ことしかありません。つまり、処理する前に永続化することです
ほかの送信元のやり方、GitHub のやり方
Stripe は 3 日間、指数バックオフでリトライします。Shopify は 4 時間で 8 回リトライします。Slack は 3 回リトライします。Paddle は約 2 日間で最大 60 回、配信を試みます。どの送信元も、エンドポイントがときどき使えなくなることを前提に設計されています。これらの数字は Webhook プロバイダー別リファレンスにまとめています。
GitHub はリクエストを送ります。10 秒以内に 2XX が返ってこなければ、失敗を記録して次に進みます。
これは見落としではなく、正当な設計判断です。GitHub が配信するイベントの量は膨大で、そのほとんどは情報提供のためのものです。その規模でリトライキューを持つにはコストがかかります。ただし、その結果として、信頼性の責任はすべて受信側に移ります。しかも、それは目立たない形で起きます。設定の流れのどこにも、このプロバイダーがほかと違う動きをするとは書かれていないからです。
3 日という期限が本当の制約
失敗した配信は Settings → Webhooks → Recent Deliveries(設定 → Webhook → 最近の配信)に表示され、どの配信でも Redeliver(再配信)をクリックできます。リポジトリ、組織、GitHub App の Webhook には、同じことをする REST API もあります。
制限は「過去 3 日に発生した Webhook 配信を再配信できます」というものです。
3 日は十分に思えますが、チームが実際にこの問題に気づくまでの流れに当てはめると話は変わります。金曜の夜のデプロイ中に Webhook が失敗します。何のアラートも出ないので、誰も Recent Deliveries タブを見ません。月曜の朝、ある PR にラベルが付かなかったのはなぜかと、誰かが尋ねます。火曜には配信記録が期限切れになり、再送どころか、何が失われたかを数え上げることさえできません。
Stripe と比べてみてください。Stripe では、配信が一度でも成功したかどうかに関係なく、イベントを 30 日間 Events API から取得できます。GitHub では、配信記録そのものがコピーです。それが期限切れになると、残る信頼できる情報源はリポジトリそのものの状態だけです。「18:40 から 18:42 の間にどの push があったか」を、コミットログから再構成することになります。
配信が失敗した理由
上から順に確認してください。
エンドポイントの応答が 10 秒を超えた
「サーバーは、webhook 配信を受信してから 10 秒以内に 2XX 応答で応答する必要があります。」
活発なモノレポの push イベントは、ペイロードが大きくなります。それをパースし、コミットの一覧をたどり、ファイルの内容を取得するために GitHub API を呼び返すハンドラーは、明らかな間違いを何もしていなくても 10 秒を超えます。差分のレビューをモデルに頼むボットなら、push のたびに超えます。GitHub 自身も、「Webhook ペイロードを非同期的に処理するキューを設定できます」と、キューを使うよう勧めています。タイムアウトチェッカーはハンドラーの応答時間を計り、10 秒と比べます。Webhook ハンドラーが LLM を呼ぶときでは、先に応答する修正をプラットフォームごとに紹介しています。
キューは有効です。ハンドラーが動き出す前に応答してしまうのも有効で、これは同じ考え方を 1 ホップ手前に移したものです。この記事の中継サービスがそれをどう実現しているかは、「Cloudflare Workers での Webhook 受信」で説明しています。2XX は約 1 秒でエッジから返るので、10 秒のうち使われるのはネットワークの分だけで、コミットをたどる処理には使われません。
シークレットが一致しない
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 です。冪等キーとして正しい理由は 2 つあります。
まず、手動で再送しても変わりません。GitHub のガイドには「再配信を要求した場合、X-GitHub-Delivery ヘッダーは元の配信と同じになります」と明記されています。そのため、失敗したデプロイの後で Redeliver をクリックしても、ハンドラーには最初に届くはずだったのと同じ ID が渡り、すでに処理したイベントだと判別できます。
また、ペイロード単位ではなくイベント単位で、これはまさに望ましい性質です。本当に別々の 2 つの push は、ペイロードが似ていても 2 つの 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 を 2 回受け取ったときにあります。
リトライしない送信元に合わせた設計
ほかのプロバイダーなら、可用性が多少甘くても、リトライスケジュールが吸収してくれます。ここでは設計上の制約が違います。保証されている配信は最初の 1 回だけなので、その 1 回を成功させなければなりません。
つまり、ハンドラーは 200 を返すまでに必要最小限の処理しかしません。署名を検証し、生のリクエストボディを永続的な場所に書き込み、応答を返します。それ以外のすべて、つまり遅くなったり例外を投げたりしやすい部分は、レスポンスの後で行います。そうすれば、GitHub の目の届かないところで、独自のスケジュールでリトライできます。
また、誰かがたまたま気づいたときではなく、3 日の猶予のうちに失敗を把握する必要があります。GitHub は教えてくれません。失敗を知らせるメールも、エンドポイント無効化の通知もなく、Shopify の警告や Stripe の停止通知にあたるものは何もありません。欠落に気づくきっかけが、誰かが Recent Deliveries タブを開くことしかないなら、それは監視ではなく運です。
コストのかからない有効なチェックがあります。定期的にイベントが届くはずのリポジトリが、普段の間隔より長く静かになったら、アラートを出すことです。これで「エンドポイントが失敗している」と「設定の整理中に Webhook が削除された」の両方を検出できます。後者は、意外によくある結末です。
AnyHook の位置づけ
構造的な問題は、GitHub が持つイベントの唯一のコピーが 3 日で消える配信記録であり、手元の唯一のコピーはハンドラーが落ちる前に書き込めた分だけ、という点にあります。
Webhook の URL を in.anyhook.net/you/github に向ければ、コピーの置き場所が変わります。AnyHook では、検証と永続化だけを担うエッジのワーカーが、約 1 秒で GitHub に 200 を返します。そのため 10 秒の応答期限が問題になることはありません。遅いハンドラーや失敗するハンドラーのせいで、GitHub が復旧しない失敗状態に配信が陥ることもなくなります。
その先は次のとおりです。
- 自分のエンドポイントへの配信は、指数バックオフでリトライされます。これが、GitHub にはないセーフティネットです
- イベントはヘッダーとボディを含めてすべて保存され、保存時に暗号化されます。GitHub の 3 日ではなく、プランの再送可能期間(Free は 3 日、Pro は 30 日、Scale は 90 日)のあいだ再送できます
- 再送は期間を指定して行えるので、金曜のデプロイ中の分も、コミット履歴から再構成するのではなく、範囲を選んでクリックするだけです
- 連続失敗が 1 回、5 回、20 回に達した時点で、失敗のアラートが届きます。これも、GitHub が送ってこない合図です
GitHub での手動の再送と、AnyHook からの再送のどちらもあるので、ハンドラーは引き続き冪等である必要があります。これは何をしても変わりません。
まとめ
Stripe 相手に身につけたデバッグの習慣は、ここではうまく通用しません。待てばリトライが来るわけでも、調べるべきデッドレターキューがあるわけでも、30 日分を補完できる API があるわけでもありません。あるのは 1 回の試行と、それが成功したかどうかの 3 日間の記録、そしてその後の沈黙だけです。
書き込み済みのイベントに対してすばやく 200 を返し、X-GitHub-Delivery で重複排除し、予期しない静けさにはアラートを設定します。今の構成で失敗した配信を復旧できないなら、それは回避策で乗り切っている GitHub の癖ではなく、自分で受け入れた設計です。