Day 12,我們用 Metrics 發現 Checkout 出現 503,再用 Trace 找到 payment.authorize 發生 Timeout。
找到原因後,最直覺的修正可能是:
失敗就再試一次。
但 Retry 不是免費的保險。
如果外部服務已經過載,所有 Client 立即重試,只會送出更多請求。如果付款已經完成,只是回應在網路中遺失,直接重試還可能造成重複扣款。
今天會用本機 Demo 比較四種情境:
以下程式看起來處理了錯誤:
try {
return await paymentService.charge(order);
} catch {
return { status: "success" };
}
它其實把失敗包裝成成功。
上層程式會繼續執行,使用者也可能看到訂單成立。系統卻不知道付款是否完成。
正確做法是先分類錯誤,再決定下一步:
| 錯誤 | 常見處理 |
|---|---|
| 輸入格式錯誤 | 回傳 400,要求使用者修正 |
| 未登入或憑證失效 | 回傳 401,不應自動重試 |
| 權限不足 | 回傳 403,不應自動重試 |
| 資源不存在 | 回傳 404,依業務情境處理 |
| 請求過多 | 遵守 429 與 Retry-After |
| 暫時性服務錯誤 | 可能重試 500、502、503、504 |
| 網路中斷或 Timeout | 先判斷操作是否適合重試 |
Retry 只適合有機會自行恢復的錯誤。權限錯誤與無效輸入不會因為等待 100 毫秒就突然變正確。
今天的程式放在:
demo-app/resilience-demo/scenario.js
進入 Demo:
cd /media/mickey/777/ithome/demo-app
執行:
npm run resilience:demo
腳本不會連接真正的付款或外部服務。所有延遲、503 與 Charge 都只存在於記憶體。
第一個 Dependency 會在 120 毫秒後回應:
async function slowDependency(durationMs, signal) {
await delay(durationMs, undefined, { signal });
return { status: 200 };
}
沒有 Timeout 時,呼叫端只能等待:
await slowDependency(120);
實際結果:
Without timeout: outcome=SUCCESS elapsed=121ms
Demo 只等待 120 毫秒。正式環境中的連線可能等待數十秒,甚至一直佔用 Socket、Memory 或 Worker。
當請求進入速度高於完成速度,系統中的 In-flight Requests 會持續增加。最後可能耗盡 Connection Pool、Thread、File Descriptor 或記憶體。
外部依賴沒有回應,也可能把自己的服務一起拖垮。
Node.js 可以使用 AbortSignal.timeout():
await slowDependency(
120,
AbortSignal.timeout(30)
);
實際結果:
With timeout: outcome=TIMEOUT elapsed=32ms
設定值是 30 毫秒,實際中止約為 32 毫秒。Event Loop 排程與電腦負載都會產生些微差異。
Timeout 不是要求外部服務必須在指定時間內完成。它是呼叫端做出的決定:
等待超過這個時間後,繼續佔用資源的成本已經高於這次請求的價值。
Timeout 發生後,程式還要:
只用 Promise.race() 回傳 Timeout,卻讓底層請求繼續執行,資源仍可能被佔用。
沒有一個數字適用所有服務。
團隊至少要考慮:
假設 Checkout 最多等待 2 秒,內部流程包含庫存、付款與資料庫:
Checkout deadline: 2000ms
├── Inventory: 300ms
├── Payment: 1000ms
├── Database: 300ms
└── Reserved: 400ms
每一層都設定 2 秒,不能保證整體在 2 秒內完成。服務要傳遞剩餘 Deadline,而不是每經過一層就重新開始計時。
Google Cloud 的 Retry 指南提出兩個判斷條件:
常見可重試情境包含:
這份清單不是「看到代碼就一定重試」。程式還要限制次數、總時間與併發量,也要判斷操作是否會產生副作用。
如果每次失敗都立即重試:
request
request
request
所有 Client 可能在相同時間再次攻擊已經過載的服務。
Exponential Backoff 會逐步增加等待時間:
baseDelay × 2 ^ (attempt - 1)
Jitter 再加入一小段隨機延遲,避免所有 Client 在同一毫秒醒來。
Demo 使用受控 Jitter,讓文章結果可以重現:
attempt=1 result=503
wait=7ms
attempt=2 result=503
wait=11ms
attempt=3 result=SUCCESS
第一次等待 7 毫秒,第二次等待 11 毫秒。第三次呼叫成功。
正式環境的 Retry Policy 還要設定:
如果已經超過使用者 Request Deadline,即使尚未達到 Maximum Attempts,也應停止重試。
假設三層服務都最多嘗試三次:
Client → Service A → Service B → Database
最差情況可能把一次 Client Request 放大為:
3 × 3 × 3 = 27 次 Database 嘗試
這種 Retry Amplification 會加速 Cascading Failure。
團隊應決定由哪一層負責重試。接近使用者的上層通常掌握整體 Deadline,接近 Dependency 的下層則最了解錯誤類型。實際選擇要根據架構,但不能讓每一層各自無限制重試。
Google SRE 指出,Client 在 RPC 已超過 Deadline 後重試,可能讓 Server 完成的工作全部浪費。額外請求又會增加 Server 負載,形成正向回饋。
付款流程可能出現以下情境:
Client 不知道第一次付款是否成功。
如果 Server 每次都建立新 Charge,Retry 就會重複扣款。
Demo 讓第一次付款成功建立 Charge,接著模擬回應遺失。Client 收到 503 後重試。
實際結果:
Charges created without idempotency: 2
使用者只按一次結帳,系統卻建立兩筆 Charge。
這個問題不是 Retry 次數太多。即使只重試一次,也可能造成重複副作用。
Client 為同一次業務操作產生固定 Key:
{
amount: 100,
idempotencyKey: "checkout-123"
}
Payment Service 第一次處理後,保存 Key 與結果:
checkout-123 → charge-1
同一個 Key 再次送達時,Server 不建立新 Charge,而是回傳原本結果。
實際結果:
Returned charge: charge-1
Charges created with idempotency: 1
Idempotency Key 需要符合以下條件:
如果 Server 先完成扣款,稍後才保存 Key,中間發生 Crash,仍可能重複執行。正式系統要讓業務操作與 Idempotency Record 維持一致。
以下回應太模糊:
{
"message": "Something went wrong"
}
Client 不知道是否可以重試,工程師也無法分類問題。
較清楚的回應可以包含:
{
"code": "PAYMENT_DEPENDENCY_TIMEOUT",
"retryable": true,
"requestId": "checkout-timeout-1"
}
但 Client 不能盲目信任 retryable: true。它仍要遵守自己的 Maximum Attempts 與 Deadline。
對外回應也不應包含 Stack Trace、Access Token 或外部服務的完整錯誤內容。
Production Readiness Agent 會呼叫 Gemini。模型 API 也是外部 Dependency,可能遇到:
Agent 需要設定:
團隊也要先確認使用中的 SDK 是否已經自動重試。如果 SDK 重試三次,應用程式外層又重試三次,實際嘗試次數可能高於預期。
Agent 應沿著每一個外部呼叫檢查:
一項可操作的 Finding 可以寫成:
問題:付款請求在網路錯誤後直接重試,但沒有 Idempotency Key
證據:charge() 每次呼叫都建立新 Charge
觸發條件:付款完成後,成功回應在傳輸途中遺失
影響:同一筆 Checkout 可能重複扣款
驗證:npm run resilience:demo
結果:一次操作建立兩筆 Charge
修正:使用 Idempotency Key,並保存首次執行結果
Agent 如果只看到 retry() 就回報問題,證據仍然不足。它要確認重試的 Operation、錯誤條件與副作用。
Timeout 限制等待成本。Retry 處理可能自行恢復的錯誤。Idempotency 則避免重試重複執行副作用。
三者必須一起設計。
今天的實驗得到四個結果:
明天,我們會處理 Health Check、容量與服務降級。當 Dependency 持續故障時,系統不能只靠 Retry 撐下去。
iThome鐵人賽