龍騰 AI 非選批閱平台 開發者文件中心
事件與通知

遞送、重試與去重

重試排程、斷路器、死信佇列,以及消費端的最佳實踐

Webhook 事件的遞送採至少一次(at-least-once)原則,因此接收客戶端的處理程式必須具備冪等性,正確處理重複遞送。

遞送失敗的條件

Webhook 遞送在遇到下列情形時將視為失敗:

  • 連線在 5 秒內無法建立(連線逾時)
  • 完整回應在 30 秒內未收到(讀取逾時)
  • 回應狀態碼不是 2xx。(3xx 重導不會被跟隨,同樣視為失敗)

遞送端點回傳任何 2xx 狀態時,即代表已受理,平台將停止送出該事件。

重試排程

每個事件有一次初次遞送,加上最多 5 次重試。退避時間表如下:

遞送距前一次失敗的延遲
初次—(發出時立即)
重試 11 分鐘
重試 25 分鐘
重試 330 分鐘
重試 42 小時
重試 58 小時

整個重試窗口自初次嘗試起算約 10.6 小時

每次重試帶有相同的 event_ididempotency_key,而 delivered_at、時間戳與簽章皆為新值。

端點斷路器

針對持續失敗的端點,將對該端點啟動斷路器,以避免大量失敗的遞送嘗試。

參數
開啟條件連續 10 次失敗
冷卻時間1 小時
半開試探遞送3 次

開啟期間,對該端點的遞送暫停,待送事件將被保留。冷卻時間結束後,將送出最多 3 次試探。連續 3 次成功,即關閉斷路器並恢復遞送保留的事件;任何一次失敗則重新開啟並再冷卻一輪。

死信佇列

初次嘗試與 5 次重試全部失敗的事件,將會進入死信佇列(DLQ)。

  • 事件將保留 30 天,之後永久刪除
  • 在 Studio 的該端點頁面可查看遞送紀錄,可依失敗狀態過濾
  • 可在 Studio 手動重送,立即排入且不受原本排程或終態 failed 影響

遞送紀錄為每個事件對每個端點一筆,初次遞送與各次重試都是 attempts 陣列中的一項,各自帶有時間戳與回應碼。

消費端的最佳實踐

所有事件均應驗證

在對該事件遞送進行任何其他處理之前,請務必檢查時間戳及簽章有效性。

成功驗證事件後立即回應

在成功驗證事件簽章後,應盡快回應 200202 狀態。需要長時間的實際工作應以非同步方式進行,避免遞送端讀取逾時造成重複遞送。

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 狀態。因此 claimEventenqueue 的順序必須嚴加確認。順序顛倒時,一次當機即會使事件永久消失(因為平台已認定它遞送成功)。

相關

本頁內容