Debug6 tháng 8, 20268 phút đọc

Không nhận được webhook từ GitHub: bạn có một lần gửi và ba ngày

GitHub không thử lại (retry) lượt gửi webhook thất bại, không một lần nào. Bản ghi lượt gửi giữ ba ngày, sau đó sự kiện mất vì deploy lỗi là mất hẳn.

MỌI NHÀ CUNG CẤP KHÁClượt gửithất bạithử lạithử lạithử lạimạng có chập chờn vài lần thì vẫn còn lần thử sauGITHUBlượt gửithất bạikhông có gìkhông có lần thử thứ hai3 ngày để phát hiện và gửi lại thủ công,sau đó bản ghi lượt gửi cũng mất luôn
Mọi bên gửi lớn khác coi lỗi là tạm thời. GitHub coi đó là kết thúc, và chỉ cho bạn ít thời gian để nhận ra.

CI của bạn không chạy. Thông báo Slack không bao giờ được đăng. Bạn mở tab Recent Deliveries của GitHub và thấy sự kiện nằm ngay đó, kèm một dấu đỏ bên cạnh, với dấu thời gian rơi đúng vào chín mươi giây ứng dụng của bạn đang khởi động lại.

Đây là chỗ khiến nhiều người bất ngờ. GitHub sẽ không gửi lại sự kiện đó. Không phải sau năm phút, không phải sau một giờ, không bao giờ. Tài liệu nói điều này trong đúng một câu, không chút rào đón: “GitHub không tự động gửi lại các lượt gửi thất bại”.

Nếu bạn đến với webhook của GitHub sau khi đã quen với Stripe hay Shopify, đây là điều ngược hẳn với những gì bạn vẫn được dạy để chờ đợi.

TL;DR

  • GitHub cho endpoint của bạn 10 giây để trả về 2XX
  • Không có cơ chế tự động thử lại (retry). Một lượt gửi thất bại là một sự kiện bị mất, trừ khi có người can thiệp
  • Bản ghi lượt gửi tồn tại 3 ngày. Sau đó bạn thậm chí không thấy được mình đã lỡ những gì
  • X-GitHub-Delivery giữ nguyên khi gửi lại thủ công, nên đó là giá trị đúng để làm idempotency key (xử lý nhiều lần vẫn cho cùng một kết quả)
  • Vì không có lưới an toàn, cách khắc phục phải là “ngay từ đầu không được thất bại”, nghĩa là lưu trước rồi mới xử lý

Các nhà cung cấp khác làm gì, và GitHub làm gì

Stripe thử lại theo exponential backoff (khoảng chờ tăng theo cấp số nhân) trong ba ngày. Shopify thử lại tám lần trong bốn giờ. Slack thử lại ba lần. Paddle gửi tối đa sáu mươi lần trong khoảng hai ngày. Bên gửi nào trong số đó cũng giả định endpoint của bạn đôi khi sẽ không truy cập được và thiết kế để xử lý chuyện đó, và các con số được tổng hợp trong tài liệu tham chiếu các nhà cung cấp webhook của chúng tôi.

GitHub gửi request. Nếu không nhận được 2XX trong vòng 10 giây, GitHub ghi nhận lỗi rồi đi tiếp.

MỌI NHÀ CUNG CẤP KHÁClượt gửithất bạithử lạithử lạithử lạimạng có chập chờn vài lần thì vẫn còn lần thử sauGITHUBlượt gửithất bạikhông có gìkhông có lần thử thứ hai3 ngày để phát hiện và gửi lại thủ công,sau đó bản ghi lượt gửi cũng mất luôn

Đó là một quyết định thiết kế có chủ đích chứ không phải sơ suất. GitHub gửi một lượng sự kiện khổng lồ, phần lớn chỉ mang tính thông tin, và một hàng đợi thử lại ở quy mô đó rất tốn kém. Nhưng quyết định này đẩy toàn bộ gánh nặng về độ tin cậy sang bạn, và làm điều đó trong im lặng, vì không có gì trong quy trình thiết lập cho bạn biết nhà cung cấp này hoạt động khác những nhà cung cấp còn lại.

Thời hạn ba ngày mới là ràng buộc thật

Các lượt gửi thất bại hiện trong Settings → Webhooks → Recent Deliveries, và bạn có thể bấm Redeliver (gửi lại) cho bất kỳ lượt nào. Cũng có REST API làm việc tương tự, cho webhook của repository, organization và GitHub App.

Giới hạn là: “Bạn có thể gửi lại các lượt gửi webhook diễn ra trong 3 ngày qua”.

Ba ngày nghe có vẻ rộng rãi, cho đến khi bạn đặt nó cạnh cách các team thực sự phát hiện chuyện này. Một webhook thất bại trong lúc deploy tối thứ Sáu. Không ai xem tab deliveries, vì chẳng có cảnh báo nào. Sáng thứ Hai, ai đó hỏi vì sao một PR mãi không được gắn nhãn. Đến thứ Ba, bản ghi lượt gửi đã hết hạn và bạn thậm chí không liệt kê được những gì đã mất, nói gì đến chuyện phát lại (replay).

So với Stripe, nơi sự kiện vẫn lấy được từ Events API trong 30 ngày, dù lượt gửi có từng thành công hay không. Với GitHub, bản ghi lượt gửi chính là bản sao. Khi nó hết hạn, nguồn sự thật duy nhất còn lại là trạng thái của repository, và bạn phải dựng lại “những lần push nào đã xảy ra từ 18:40 đến 18:42” từ commit log.

Vì sao lượt gửi thất bại

Kiểm tra lần lượt theo danh sách dưới đây.

Endpoint mất hơn 10 giây

“Server của bạn nên trả về phản hồi 2XX trong vòng 10 giây sau khi nhận được một lượt gửi webhook”.

Một sự kiện push trên một monorepo bận rộn mang payload lớn, và một handler (đoạn code xử lý webhook trên server của bạn) parse payload đó, duyệt danh sách commit rồi gọi ngược vào GitHub API để lấy nội dung file sẽ vượt mười giây mà không làm gì sai rõ ràng. Một bot nhờ mô hình review diff thì vượt mốc đó ở mọi lần push. Chính GitHub khuyên dùng hàng đợi: “bạn có thể muốn thiết lập một hàng đợi để xử lý payload webhook bất đồng bộ”. Công cụ kiểm tra timeout đo handler của bạn so với mười giây đó, và bài Handler webhook của bạn gọi LLM trình bày cách khắc phục theo hướng trả lời trước trên từng nền tảng.

Dùng hàng đợi thì được. Trả lời trước cả khi handler bắt đầu chạy cũng được, đó là cùng một ý tưởng nhưng dời sớm hơn một chặng. Bài Nhận webhook trên Cloudflare Workers là cách dịch vụ trung chuyển webhook (webhook relay) trong bài này làm: mã 2XX rời edge (lớp máy chủ biên của Cloudflare) sau khoảng một giây, nên mười giây kia chỉ tiêu vào đường truyền chứ không phải vào việc duyệt commit của bạn.

Secret không khớp

Nếu webhook có cấu hình secret (khóa bí mật dùng để ký), GitHub ký bằng X-Hub-Signature-256 và bước xác minh của bạn phải chạy trên raw body. Serialize lại JSON trước khi băm sẽ cho ra các byte khác, xác minh không qua, endpoint trả về 4xx, và đó được tính là một lượt gửi thất bại không có thử lại. Chi tiết cho từng nhà cung cấp nằm trong bài Xác minh chữ ký webhook của Stripe, GitHub và Shopify.

Có thứ gì đó phía trước ứng dụng đã chặn request

Một rule WAF, một bộ lọc bot hoặc một middleware xác thực đòi session cookie. Traffic webhook là các POST ẩn danh từ một dải IP của datacenter, đúng kiểu mà phần lớn rule bảo vệ được tinh chỉnh để chặn. Response mà GitHub ghi lại sẽ là 401, 403 hoặc 429, chứ không phải thứ gì từ code của bạn.

Loại sự kiện chưa được đăng ký nhận

Lượt gửi hoàn toàn không xuất hiện, thay vì xuất hiện rồi thất bại. Kiểm tra danh sách sự kiện của webhook.

Đó là GitHub App và bản cài đặt đã mất quyền truy cập

Webhook của GitHub App ngừng gửi cho các repository mà bản cài đặt đã bị gỡ khỏi. Tab deliveries không hiện gì, trông y hệt trường hợp “trigger chưa bao giờ kích hoạt”.

Idempotency key

X-GitHub-Delivery là một GUID định danh sự kiện. Hai đặc điểm khiến nó là key đúng:

Nó giữ nguyên khi gửi lại thủ công. Hướng dẫn của GitHub nói rõ: “nếu bạn yêu cầu gửi lại, header X-GitHub-Delivery sẽ giống như ở lượt gửi ban đầu”, nên khi bạn bấm Redeliver sau một lần deploy lỗi, handler của bạn thấy đúng ID mà nó lẽ ra đã thấy ở lần đầu và nhận ra được đây là sự kiện đã xử lý rồi.

Nó gắn với từng sự kiện chứ không phải từng payload, đúng điều bạn cần. Hai lần push thật sự riêng biệt cho ra hai ID, kể cả khi payload na ná nhau.

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 });
}

Với bản trùng, trả về 200, đừng trả lỗi. Bộ pattern đầy đủ hơn, kể cả cách làm khi bước ghi dấu và side effect không thể nằm chung một transaction, có trong bài Bạn nhận cùng một webhook hai lần.

Thiết kế cho một bên gửi không thử lại

Với mọi nhà cung cấp khác, bạn có thể hơi lỏng tay về độ sẵn sàng, vì lịch thử lại sẽ gánh giúp. Ở đây ràng buộc thiết kế khác hẳn: lượt gửi duy nhất bạn chắc chắn có là lượt đầu tiên, nên lượt đầu tiên phải thành công.

Nghĩa là handler làm ít việc nhất có thể trước khi trả 200. Xác minh chữ ký, ghi raw body vào một chỗ bền vững, rồi trả về. Mọi thứ khác, kể cả những phần dễ chậm hoặc ném lỗi nhất, diễn ra sau response và có thể tự thử lại theo lịch riêng mà không cần GitHub để mắt tới.

Điều đó cũng có nghĩa là bạn cần biết về lỗi trong vòng ba ngày đó, chứ không phải vào lúc nào đó có người tình cờ để ý. GitHub sẽ không báo cho bạn. Không có email báo lỗi, không có thông báo endpoint bị vô hiệu hóa, không có gì tương đương cảnh báo của Shopify hay thông báo ngắt của Stripe. Nếu thứ duy nhất có thể làm lộ ra một khoảng trống là một người mở tab deliveries, bạn không có giám sát, bạn chỉ có may mắn.

Một phép kiểm tra hữu ích mà không tốn gì: cảnh báo khi một repository vốn gửi sự kiện đều đặn bỗng im lặng lâu hơn khoảng nghỉ bình thường của nó. Cách này bắt được cả “endpoint của chúng tôi đang lỗi” lẫn “webhook bị xóa trong lúc dọn dẹp phần cài đặt”, một kết cục phổ biến đến bất ngờ.

AnyHook giúp được gì

Vấn đề mang tính cấu trúc là bản sao duy nhất của sự kiện phía GitHub là một bản ghi lượt gửi với ngòi nổ ba ngày, còn bản sao duy nhất phía bạn là bất cứ thứ gì handler kịp ghi trước khi gục.

Trỏ webhook về in.anyhook.net/you/github và bản sao sẽ nằm ở chỗ khác. AnyHook trả 200 cho GitHub sau khoảng một giây, từ một worker ở edge chỉ làm đúng một việc là xác minh rồi lưu, nên thời hạn 10 giây không bao giờ bị chạm tới, và một handler chậm hay đang lỗi không còn đẩy được lượt gửi vào trạng thái thất bại mà GitHub không khôi phục.

Từ đó:

  • Lượt gửi tới endpoint của bạn được thử lại theo exponential backoff, tấm lưới an toàn mà GitHub không có
  • Sự kiện được lưu kèm đầy đủ header và body, được mã hóa khi lưu trữ, và phát lại được trong thời hạn phát lại của gói (3 ngày với gói Free, 30 ngày với gói Pro, 90 ngày với gói Scale), thay vì 3 ngày của GitHub
  • Phát lại được theo cả một khoảng thời gian, nên khung giờ deploy tối thứ Sáu chỉ cần chọn rồi bấm, thay vì dựng lại từ lịch sử commit
  • Cảnh báo lỗi được gửi ở mốc 1, 5 và 20 lần thất bại liên tiếp, đúng tín hiệu mà GitHub không gửi

Handler của bạn vẫn cần idempotent, vì cả gửi lại thủ công lẫn phát lại đều tồn tại. Không gì gỡ bỏ được yêu cầu đó.

Điều cần nhớ

Mọi thói quen gỡ lỗi bạn xây dựng với Stripe đều khó áp dụng ở đây. Không có lần thử lại nào để chờ, không có dead letter queue (hàng đợi chứa các message không xử lý được) nào để kiểm tra và không có API ba mươi ngày nào để lấy bù (backfill). Chỉ có một lần gửi, một bản ghi ba ngày cho biết nó có thành công không, rồi im lặng.

Trả 200 thật nhanh cho thứ bạn đã ghi lại, chống trùng lặp dựa trên X-GitHub-Delivery, và đặt cảnh báo cho những khoảng im lặng bất thường. Nếu trong hệ thống hiện tại của bạn, một lượt gửi thất bại là không khôi phục được, thì đó không phải một điểm kỳ quặc của GitHub mà bạn đang phải lách, mà là thiết kế bạn đã chấp nhận.

Tất cả bài viết6 tháng 8, 2026 · 8 phút

Đừng để mất webhook nữa.

Đổi một URL. Có ngay cơ chế thử lại, nhật ký sự kiện và phát lại bằng một cú bấm. Hoặc xem tận mắt trước: đăng ký, chạy bài diễn tập sự cố 30 giây và xem một sự kiện vẫn còn nguyên khi server của bạn ngừng hoạt động. Không cần có server.