前言
這個案例要把 Shopify 訂單與會員異動同步到 Ragic。最初看起來只要在 Webhook route 收到資料後呼叫 Ragic API;但外部 API 變慢、暫時失敗或 Shopify 重送事件時,一個 HTTP request 便同時背負接收、轉換、寫入、重試與去重責任。
上一篇談的是寫入 Ragic 前的受控 Dry Run。正式開啟自動同步後,第一個要拆開的責任就是:「Shopify 已把事件送到 App」不等於「Ragic 已完成寫入」。
原本的做法與問題症狀
第一版直覺流程是:
Webhook → 驗證 → Mapping → 查詢 Ragic → Create / Update → 回應 Shopify
只要 Ragic 查詢、寫入或網路延遲,Webhook 回應時間就被外部系統綁住。若 endpoint 沒有及時回傳成功,Shopify 會把 delivery 視為失敗並重試;若第一次其實已寫入 Ragic、只是回應途中斷線,第二次處理又可能建立重複資料。
反過來,若 route 一收到 request 就回傳成功、工作卻只存在記憶體,process restart 後事件同樣會遺失。因此「快速 ACK」與「可靠接收」必須同時成立:先把工作持久化成功,才回應 2xx。
問題是怎麼推敲出來的?
來源 Git 歷史先把四個 Webhook route 改成建立 SyncJob,再加入 job processor 與 retry 狀態。測試證明 route 會先經 Shopify authentication helper 驗證 delivery;建立 job 失敗時回傳非成功狀態,而不是假裝已接收。
SyncJob 保存 shop、topic、target、來源、resource ID、payload snapshot、attempts、nextRunAt 與狀態。Processor 只挑可執行的 pending job,標成 running,完成後寫 success;失敗且仍有次數時回到 pending 並延後下一次執行,最後一次才進入 error。
去重也不是只有一層:
- 相同
orders/create或customers/create已有成功同步紀錄時,processor 可留下 skipped log。 orders/updated與customers/update不能因 resource ID 相同就略過,因為內容可能真的改變。- 寫入 Ragic 前仍以 Mapping 指定的 unique key 查詢,決定 create 或 update。
這些機制處理的是「相同資源重送」與「遠端資料是否已存在」。目前案例沒有保存 Shopify 的 Webhook delivery ID,因此還不能宣稱已做到 delivery-level dedup。
最後採用的架構
接收與執行拆成兩段:
Shopify Webhook
→ 驗證 request
→ 持久化 SyncJob
→ 回傳 2xx
Scheduler / Worker
→ claim pending job
→ Mapping + payload transform
→ Ragic unique-key lookup
→ create / update
→ SyncLog
→ success / retry / error
Webhook route 的成功標準是「工作已安全落地」,不是「所有 downstream side effect 已完成」。商家畫面則要分別顯示 pending、running、success 與 error,避免把 HTTP 200 誤讀為同步成功。
關鍵實作
Route 只把已驗證資料交給 enqueue:
try {
await enqueueSyncJob({
shop,
target: 'order',
topic: 'orders/create',
payload,
shopifyResourceId,
});
return new Response();
} catch {
return new Response('工作無法落地', { status: 500 });
}
失敗時依 attempts 決定重試或終止:
const exhausted = job.attempts >= job.maxAttempts;
await updateJob(job.id, {
status: exhausted ? 'error' : 'pending',
nextRunAt: exhausted ? job.nextRunAt : addRetryDelay(now, job.attempts),
lastError: safeErrorMessage,
});
公開範例省略真實 payload、商店識別與 worker secret。實務上錯誤訊息也應先清理,不能把外部 response、顧客資料或 credential 原樣寫進 log。
實際驗證
來源測試已覆蓋:
- Webhook route 建立 pending job,保存 payload snapshot 與 resource ID。
- 沒有 runnable job 時 processor 不執行外部呼叫。
- 成功工作依序進入 running 與 success,並建立 SyncLog。
- 外部寫入失敗時增加 attempt、設定下一次執行時間。
- 剩餘次數用完後標記 error,不再無限重試。
orders/create的成功事件可被略過,update topic 仍會執行。- Internal worker endpoint 要通過 server-side secret 驗證,且可限制單批處理數量。
這些是 repository 內的單元與整合層測試,不能代替正式環境的併發、網路中斷、長時間 outage 與 rate-limit 演練。
仍然存在的限制
目前 processor 的「先找一筆 pending,再標成 running」是兩次資料庫操作。多個 worker 同時執行時,仍可能挑到同一筆工作;需要 atomic claim、transaction、lease 或資料庫鎖。
重試也不等於 exactly-once。Shopify Webhook、worker 與 Ragic API 之間沒有跨系統 transaction,consumer 必須把 side effect 設計成可重入,並保存 Shopify delivery ID 或等價 idempotency key 才能縮小重複處理範圍。
Shopify 官方也提醒 Webhook 可能重複、延遲、亂序或漏送。可靠同步仍需要 reconciliation job,定期依更新時間與穩定游標對照 Shopify 來源,不可只依賴 Webhook。
最後,來源案例已有 worker endpoint 與 runbook,但 repository 不能證明 production scheduler 已持續運行、也沒有多 worker 壓測。文章因此只稱它為 retry foundation,不稱為已驗證的完整 durable queue。
可以延伸到哪些情境?
相同拆法適用 ERP、CRM、倉儲與會計整合:入口先驗證並持久化;worker 承擔 retry;遠端寫入依 unique key 或 idempotency key 收斂;reconciliation 再修補漏送與亂序。
下一篇會處理另一種來源:Shopify 歷史訂單 Backfill,如何避免重複同步與漏資料?
參考資料
- Shopify:About webhooks
- Shopify:Troubleshoot webhooks
- Shopify:Verify webhook deliveries
- Shopify:Work with protected customer data
- Ragic:API Request Error Response
以上平台文件查核日期:2026-07-27。
