iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Build on Google AI

從 Vibe Coding 到 Production:用 Google AI 打造上線守門員系列 第 13

Day 13|錯誤處理、Timeout 與 Retry:避免小故障變成大事故

  • 分享至 

  • xImage
  •  

Day 12,我們用 Metrics 發現 Checkout 出現 503,再用 Trace 找到 payment.authorize 發生 Timeout。

找到原因後,最直覺的修正可能是:

失敗就再試一次。

但 Retry 不是免費的保險。

如果外部服務已經過載,所有 Client 立即重試,只會送出更多請求。如果付款已經完成,只是回應在網路中遺失,直接重試還可能造成重複扣款。

今天會用本機 Demo 比較四種情境:

  1. 呼叫外部服務時完全沒有 Timeout。
  2. 使用 Timeout 主動停止等待。
  3. 遇到暫時性錯誤時使用 Backoff 與 Jitter。
  4. 付款回應遺失後,有無 Idempotency Key 的差異。

錯誤處理不是把所有錯誤 Catch 起來

以下程式看起來處理了錯誤:

try {
  return await paymentService.charge(order);
} catch {
  return { status: "success" };
}

它其實把失敗包裝成成功。

上層程式會繼續執行,使用者也可能看到訂單成立。系統卻不知道付款是否完成。

正確做法是先分類錯誤,再決定下一步:

錯誤 常見處理
輸入格式錯誤 回傳 400,要求使用者修正
未登入或憑證失效 回傳 401,不應自動重試
權限不足 回傳 403,不應自動重試
資源不存在 回傳 404,依業務情境處理
請求過多 遵守 429 與 Retry-After
暫時性服務錯誤 可能重試 500、502、503、504
網路中斷或 Timeout 先判斷操作是否適合重試

Retry 只適合有機會自行恢復的錯誤。權限錯誤與無效輸入不會因為等待 100 毫秒就突然變正確。

建立 Day 13 的本機情境

今天的程式放在:

demo-app/resilience-demo/scenario.js

進入 Demo:

cd /media/mickey/777/ithome/demo-app

執行:

npm run resilience:demo

腳本不會連接真正的付款或外部服務。所有延遲、503 與 Charge 都只存在於記憶體。

沒有 Timeout,等多久由對方決定

第一個 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 或記憶體。

外部依賴沒有回應,也可能把自己的服務一起拖垮。

使用 Timeout 限制等待時間

Node.js 可以使用 AbortSignal.timeout()

await slowDependency(
  120,
  AbortSignal.timeout(30)
);

實際結果:

With timeout: outcome=TIMEOUT elapsed=32ms

設定值是 30 毫秒,實際中止約為 32 毫秒。Event Loop 排程與電腦負載都會產生些微差異。

Timeout 不是要求外部服務必須在指定時間內完成。它是呼叫端做出的決定:

等待超過這個時間後,繼續佔用資源的成本已經高於這次請求的價值。

Timeout 發生後,程式還要:

  1. 中止仍在執行的網路請求。
  2. 回傳明確錯誤,不能偽裝成成功。
  3. 記錄 Dependency、Timeout 值與 Trace ID。
  4. 釋放連線、Timer 與其他資源。

只用 Promise.race() 回傳 Timeout,卻讓底層請求繼續執行,資源仍可能被佔用。

Timeout 要設多少?

沒有一個數字適用所有服務。

團隊至少要考慮:

  • 使用者可以接受的總等待時間。
  • 上游傳入的 Request Deadline。
  • 外部服務正常的 P95 與 P99 Latency。
  • 網路連線、TLS 與 DNS 所需時間。
  • 後續是否還要執行 Retry。
  • Timeout 後能否安全取消工作。

假設 Checkout 最多等待 2 秒,內部流程包含庫存、付款與資料庫:

Checkout deadline: 2000ms
├── Inventory:      300ms
├── Payment:       1000ms
├── Database:       300ms
└── Reserved:       400ms

每一層都設定 2 秒,不能保證整體在 2 秒內完成。服務要傳遞剩餘 Deadline,而不是每經過一層就重新開始計時。

不是所有錯誤都應該 Retry

Google Cloud 的 Retry 指南提出兩個判斷條件:

  1. Response 是否代表暫時性問題。
  2. Operation 是否具備 Idempotency。

常見可重試情境包含:

  • 408 Request Timeout
  • 429 Too Many Requests
  • 500 Internal Server Error
  • 502 Bad Gateway
  • 503 Service Unavailable
  • 504 Gateway Timeout
  • Socket Timeout
  • TCP Connection 中斷

這份清單不是「看到代碼就一定重試」。程式還要限制次數、總時間與併發量,也要判斷操作是否會產生副作用。

使用 Exponential Backoff 與 Jitter

如果每次失敗都立即重試:

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 還要設定:

  • Maximum Attempts
  • Maximum Backoff
  • Total Retry Deadline
  • Retryable Status
  • Jitter Strategy
  • Cancellation Signal

如果已經超過使用者 Request Deadline,即使尚未達到 Maximum Attempts,也應停止重試。

Retry 疊在每一層會發生什麼事?

假設三層服務都最多嘗試三次:

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 收到成功回應

付款流程可能出現以下情境:

  1. Payment Service 完成扣款。
  2. Payment Service 準備回傳成功。
  3. 網路連線中斷。
  4. Client 只看到 503 或 Connection Reset。
  5. Client 再送一次付款請求。

Client 不知道第一次付款是否成功。

如果 Server 每次都建立新 Charge,Retry 就會重複扣款。

沒有 Idempotency 的付款 Retry

Demo 讓第一次付款成功建立 Charge,接著模擬回應遺失。Client 收到 503 後重試。

實際結果:

Charges created without idempotency: 2

使用者只按一次結帳,系統卻建立兩筆 Charge。

這個問題不是 Retry 次數太多。即使只重試一次,也可能造成重複副作用。

使用 Idempotency Key

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 需要符合以下條件:

  • 同一次業務操作重試時使用相同 Key。
  • 不同業務操作不能共用 Key。
  • Server 要以原子操作保存 Key 與結果。
  • Key 要有合理保存期限。
  • 相同 Key 搭配不同 Payload 時應拒絕請求。

如果 Server 先完成扣款,稍後才保存 Key,中間發生 Crash,仍可能重複執行。正式系統要讓業務操作與 Idempotency Record 維持一致。

Retry 失敗後應回傳什麼?

以下回應太模糊:

{
  "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 或外部服務的完整錯誤內容。

Gemini API 呼叫也需要這些控制

Production Readiness Agent 會呼叫 Gemini。模型 API 也是外部 Dependency,可能遇到:

  • Rate Limit
  • 暫時性服務錯誤
  • 網路中斷
  • 回應時間超過使用者 Deadline
  • Client 已取消請求,但後端仍在生成

Agent 需要設定:

  1. 單次 Gemini Request Timeout。
  2. 整體 Review Deadline。
  3. 有上限的 Retry Policy。
  4. 429 與暫時性 5xx 的處理方式。
  5. 使用者取消後的中止機制。
  6. 重試次數、等待時間與最終結果的 Metric。

團隊也要先確認使用中的 SDK 是否已經自動重試。如果 SDK 重試三次,應用程式外層又重試三次,實際嘗試次數可能高於預期。

Production Readiness Agent 要檢查什麼?

Agent 應沿著每一個外部呼叫檢查:

  1. 是否設定 Timeout 或 Deadline。
  2. Timeout 後是否真的取消底層工作。
  3. 哪些錯誤會觸發 Retry。
  4. Retry 是否限制次數與總時間。
  5. 是否使用 Exponential Backoff 與 Jitter。
  6. 上下游是否同時重試,造成放大。
  7. 寫入、付款或建立資源是否具備 Idempotency。
  8. Retry 與 Timeout 是否產生 Metric、Log 與 Trace。

一項可操作的 Finding 可以寫成:

問題:付款請求在網路錯誤後直接重試,但沒有 Idempotency Key
證據:charge() 每次呼叫都建立新 Charge
觸發條件:付款完成後,成功回應在傳輸途中遺失
影響:同一筆 Checkout 可能重複扣款
驗證:npm run resilience:demo
結果:一次操作建立兩筆 Charge
修正:使用 Idempotency Key,並保存首次執行結果

Agent 如果只看到 retry() 就回報問題,證據仍然不足。它要確認重試的 Operation、錯誤條件與副作用。

今天的結論

Timeout 限制等待成本。Retry 處理可能自行恢復的錯誤。Idempotency 則避免重試重複執行副作用。

三者必須一起設計。

今天的實驗得到四個結果:

  • 沒有 Timeout 時,Caller 等待 Dependency 完整回應。
  • 30 毫秒 Timeout 讓 Caller 約在 32 毫秒停止等待。
  • Exponential Backoff 與 Jitter 分散重試時間。
  • 付款沒有 Idempotency 時建立兩筆 Charge,加入固定 Key 後只建立一筆。

明天,我們會處理 Health Check、容量與服務降級。當 Dependency 持續故障時,系統不能只靠 Retry 撐下去。

參考資料


上一篇
Day 12|服務掛掉以前看得見嗎?Logs、Metrics 與 Traces 入門
系列文
從 Vibe Coding 到 Production:用 Google AI 打造上線守門員13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言