遞送、重試與去重
重試排程、斷路器、死信佇列,以及消費端的最佳實踐
Webhook 事件的遞送採至少一次(at-least-once)原則,因此接收客戶端的處理程式必須具備冪等性,正確處理重複遞送。
遞送失敗的條件
Webhook 遞送在遇到下列情形時將視為失敗:
- 連線在 5 秒內無法建立(連線逾時)
- 完整回應在 30 秒內未收到(讀取逾時)
- 回應狀態碼不是
2xx。(3xx重導不會被跟隨,同樣視為失敗)
遞送端點回傳任何 2xx 狀態時,即代表已受理,平台將停止送出該事件。
重試排程
每個事件有一次初次遞送,加上最多 5 次重試。退避時間表如下:
| 遞送 | 距前一次失敗的延遲 |
|---|---|
| 初次 | —(發出時立即) |
| 重試 1 | 1 分鐘 |
| 重試 2 | 5 分鐘 |
| 重試 3 | 30 分鐘 |
| 重試 4 | 2 小時 |
| 重試 5 | 8 小時 |
整個重試窗口自初次嘗試起算約 10.6 小時。
每次重試帶有相同的 event_id 與 idempotency_key,而 delivered_at、時間戳與簽章皆為新值。
端點斷路器
針對持續失敗的端點,將對該端點啟動斷路器,以避免大量失敗的遞送嘗試。
| 參數 | 值 |
|---|---|
| 開啟條件 | 連續 10 次失敗 |
| 冷卻時間 | 1 小時 |
| 半開試探遞送 | 3 次 |
開啟期間,對該端點的遞送暫停,待送事件將被保留。冷卻時間結束後,將送出最多 3 次試探。連續 3 次成功,即關閉斷路器並恢復遞送保留的事件;任何一次失敗則重新開啟並再冷卻一輪。
死信佇列
初次嘗試與 5 次重試全部失敗的事件,將會進入死信佇列(DLQ)。
- 事件將保留 30 天,之後永久刪除
- 在 Studio 的該端點頁面可查看遞送紀錄,可依失敗狀態過濾
- 可在 Studio 手動重送,立即排入且不受原本排程或終態
failed影響
遞送紀錄為每個事件對每個端點一筆,初次遞送與各次重試都是 attempts 陣列中的一項,各自帶有時間戳與回應碼。
消費端的最佳實踐
所有事件均應驗證
在對該事件遞送進行任何其他處理之前,請務必檢查時間戳及簽章有效性。
成功驗證事件後立即回應
在成功驗證事件簽章後,應盡快回應 200 或 202 狀態。需要長時間的實際工作應以非同步方式進行,避免遞送端讀取逾時造成重複遞送。
以 event_id 去重
遞送採至少一次原則,因此請務必能正確處理重複的事件。請以 event_id(或等效的 idempotency_key)作為去重鍵,保留時間至少與重試窗口(10.6 小時)相當或更多。
切勿以內容雜湊去重。因 delivered_at 時間戳每次遞送都會改變,
因此重試的內容並非逐位元組相同。使用雜湊會將重試誤判為新事件。
亦不可使用 X-Norma-Delivery-Id,該值每次遞送重試亦皆不同。
以 sequence 重建順序
由於遞送採用至少一次原則,且各事件各自重試,因此事件遞送順序無法保證與實際發生順序相同。請以 sequence 重建每個資源的順序:
sequence小於或等於已處理的最後一個值:屬於重複或過期事件,回應 2xx 並略過。- 恰為下一個期望值:進行處理。
- 出現缺口(跳過了期望值):不要等待遺失的事件,請重新查詢資源並自其現況繼續。API 是事實來源,webhook 僅為變更通知。
依照訂閱事件種類,sequence 的遞增可能存在缺口,屬於正常現象。
以 API 為事實來源
Webhook 事件的 payload 是精簡的摘要,僅帶有識別碼與變更概況。請透過 API 重新取得資源的完整狀態。
失敗時明確回報
若未能將事件持久化,請回應非 2xx 的狀態,或讓請求逾時,使遞送可以被重試。
回應 2xx 將等同於告知平台該事件已處理完成,遞送將就此停止。因此若逕行略過一個處理失敗的事件,該事件將永久遺失。
處理程式範例
import express from 'express';
const app = express();
app.post(
'/webhooks/norma',
express.raw({ type: 'application/json' }), // 原始位元組:簽章驗證所需
async (req, res) => {
let event;
try {
event = verifyWebhook(
req.headers,
req.body,
process.env.NORMA_WEBHOOK_SECRET!,
);
} catch {
// 簽章不符的請求本不應受理,無須重試
return res.status(400).send('invalid signature');
}
try {
// 去重與入列必須是原子操作:先寫入才算受理
const isNew = await claimEvent(event.event_id); // 例如 INSERT ... ON CONFLICT DO NOTHING
if (isNew) await enqueue(event);
} catch (err) {
// 未能持久化:明確回報失敗,讓平台重試
return res.status(500).send('could not persist');
}
res.status(202).send(); // 快速回應,實際處理交由佇列
},
);在本範例程式中,必須先確保事件已被持久地記錄,才回應 2xx 狀態。因此 claimEvent 與 enqueue 的順序必須嚴加確認。順序顛倒時,一次當機即會使事件永久消失(因為平台已認定它遞送成功)。
相關
- 接收 Webhook — 註冊、簽章、密鑰輪換
- Webhook 事件參考 — 各事件的 payload
- 錯誤處理策略 — 同步 API 呼叫端的處理