結論先說:failure mode 不是一張「可能出錯」的清單,而是某個條件成立時,誰會看見什麼、系統要少做什麼、何時要直接拒絕的可操作契約。(本篇拆成上下兩篇:上篇談定義、失敗傳播與六種死法,下篇談 configuration failure 與調查流程。)
Day 10 把 Fault、Error、Failure 分開了。今天往前一步:不等值班時才猜「timeout 要不要 retry」,而是在系統還健康時,先替每條 critical path 設計難看的死法。
這篇只談傳統 production failure:crash、timeout、network、resource、configuration 與 dependency。Day 12 才會處理 AI workflow 特有的 retrieval、tool、model 與 semantic failure。不要混在一起。
今天的讀者練習沿用 Day 2 的 FastAPI SRE Lab。程式、指令與預期結果是讓讀者在自己的隔離環境操作的步驟;本文沒有建立 Day11/DIY、安裝套件、啟動服務、發出請求或驗證結果。
「CPU high」不是 failure mode。
「P95 latency alert」也不是。
它們是訊號,提醒你某件事可能正在發生。
一個能拿來操作的 failure mode 至少要回答五件事:
觸發條件
↓
執行期 error
↓
使用者看見的 failure
↓
可觀測訊號
↓
系統的減傷與禁止動作
以 /ask 呼叫 policy provider 為例:
provider 回應超過 client deadline ← 觸發條件
↓
asyncio.TimeoutError ← Error
↓
使用者收到明確的暫時不可用回應 ← Failure handling
↓
timeout counter、trace、structured log ← Evidence
↓
停止昂貴工作;有限度 retry 或 fallback ← Mitigation
注意最後一行不是「一定 retry」。
如果 downstream 已經過載,再塞一次請求過去,通常不是幫忙,是補刀。
| 執行期 error | 系統行為 | 使用者結果 | 是否已經恢復服務合約? |
|---|---|---|---|
| provider 一次連線失敗 | 指定層 retry 一次,第二次成功 | 收到答案,延遲略高 | 可能;仍要保留 retry evidence |
| provider timeout | 讀取明確標示的新鮮 cache | 收到快取結果與來源時間 | degraded success |
| provider timeout | 沒有合格 fallback,回 503 |
知道暫時無法完成 | 沒有,但合約誠實 |
| provider timeout | 回傳流暢但未驗證的答案 | 可能照錯誤資訊行動 | 沒有,且引入 quality risk |
這張表想凸顯的是:「執行期發生了 timeout」本身不足以決定使用者最終看到什麼。第二列跟第三列的觸發條件相同,都是 provider timeout,卻因為減傷設計不同,一個走到 degraded success,一個走到誠實的失敗——有沒有合格的 fallback 快取、有沒有把「回傳前先驗證來源」寫進邏輯,決定了它落在表格的哪一列。
第四列最危險,因為傳統 dashboard 可能是一片綠。
Day 7 已經說過:HTTP 200 不等於回答可用。今天先不要用「成功」掩蓋 dependency failure;明天再處理 AI workflow 如何在 HTTP 200 裡失敗。
拆成五段不是形式主義。少掉任何一段,這張卡片在事故現場就會失去用處:
少了「觸發條件」 → 值班人員只能用猜的,判斷不出現在符不符合這個 failure mode
少了「執行期 error」→ 對不上程式碼真正拋出的例外類別,排查變成大海撈針
少了「使用者看見的 failure」→ 團隊會用「技術上有沒有壞」取代「使用者有沒有受影響」
少了「可觀測訊號」 → 事後才發現這個情境根本沒有 metric、log 或 trace 能證明它發生過
少了「減傷與禁止動作」→ 值班人員只能臨場發明對策,而臨場發明常常就是下一次事故的起點
這五段合起來,把一句模糊的「這個服務不穩定」,換成兩個人在不同時間讀了也會得出同樣結論的契約。
很多團隊把「列出可能出錯的地方」當成終點:
可能失敗點:
- provider 掛掉
- 資料庫連不上
- 記憶體不夠
- 網路不穩
這張清單只回答了「什麼可能壞」,沒回答「壞了之後系統要怎麼表現」。runbook 通常是事故發生後、依失敗類型決定「人要做什麼」;failure mode 卡片則寫在事故發生前,決定「系統本身要做什麼」——沒有卡片打底的 runbook,經常是一長串「先做這個,再做那個」卻答不出「為什麼是這個順序」的操作手冊。
同樣的誤解也會發生在告警數量上:把每個 metric 波動都設成告警,值班人員很快就會對告警免疫(alert fatigue),開始用「大概又是那個」的心態略過訊號,包括真正重要的那一次。真正該問的不是「有沒有設告警」,而是「這個告警對應哪一個 failure mode,觸發時我們知道要看什麼、做什麼嗎」——答不出來,它就還是個未完成的設計。
Day 2 的 Lab 把 request 拆成 FastAPI、metrics、logs 和 traces。今天只取其中一條很小的路徑:
Client
↓
POST /ask
↓
FastAPI handler
↓
policy provider
↓
response contract
正常時它很短。
失敗時,真正重要的是 deadline、重試位置與回應契約:
Client deadline: 1.0 s
↓
FastAPI 可用時間: 約 0.9 s
↓
provider call deadline: 0.25 s
↓
最多一次 retry,且只有 retriable failure
↓
cache fallback 或明確 503
這些數字只是 Lab 假設,不是 production SLO,也不是通用建議值。production 的 deadline 要看使用者承諾、正常與尾端延遲、連線建立時間、依賴服務容量,以及是否有後續工作。
Google SRE 的級聯失敗章節指出,逾時後仍在下游執行的工作不會產生使用者價值,卻會繼續消耗資源;deadline propagation 的目的,就是讓每一層知道剩下多少時間可以做有用的工作。Google SRE Book
這是 deadline propagation 最常被誤解的地方。很多實作把每一層的 timeout 想成各自獨立設定的固定數字:FastAPI handler timeout = 1.0s、provider client timeout = 0.25s,兩個數字互不相干。這樣有一個隱藏的浪費:假設 FastAPI handler 已經花了 0.8 秒排隊等 worker,才輪到呼叫 provider,provider client 若還是傻傻給自己 0.25 秒,使用者總共要等 1.05 秒——已經超過原本承諾的 1.0 秒 deadline,卻沒有人在中途發現。
正確的心智模型是「deadline 是一個從最外層開始遞減的時間帳戶」,而不是每一層各自幫自己上一道保險絲:
Client 承諾 deadline: 1.0 s(從 request 進來那一刻開始算)
↓ FastAPI handler 花了 0.8s 排隊
剩餘預算 = 1.0 - 0.8 = 0.2 s
↓ provider client 這一步最多只能拿 min(0.25s 上限, 0.2s 剩餘預算)
實際 timeout = 0.2 s,而不是寫死的 0.25 s
這也是為什麼本文範例程式碼裡,call_provider_with_budget() 會先算 remaining = deadline - time.monotonic(),再拿它跟 CLIENT_TIMEOUT_SECONDS 取最小值,而不是每次呼叫都直接套用同一個常數。deadline 沿著呼叫鏈往下傳遞時,往下傳的應該是「還剩多少」,不是「原本設定多少」——「每一層都有 timeout」是必要條件,不是充分條件,沒有把剩餘預算往下傳,各層 timeout 就只是各自為政的保險絲:上游已經放棄,下游卻還在傻傻做工。
Google SRE Book 記錄過一次很適合放在這裡的案例:內部的 Shakespeare Search 服務在一次流量異常時中斷了 66 分鐘,根因不是外部攻擊,而是自己的例外處理路徑沒有把資源釋放乾淨。
事故的鏈條大致是這樣:
一小部分搜尋請求開始失敗
↓
失敗路徑上,連線與執行緒沒有被正確釋放(資源洩漏)
↓
正常流量持續進來,每次失敗都再洩漏一點資源
↓
連線池、執行緒池被慢慢吃光
↓
新的請求連取得資源都做不到,開始大量逾時
↓
逾時本身又提高了失敗率,回到第一步,形成正回授迴圈
這個案例示範了「deadline 沒有被正確遵守」的另一種樣貌:不是請求真的需要那麼久,而是失敗路徑上的清理工作沒有在該結束的時候結束,讓一個原本只該存在一次呼叫份量的資源占用,變成長期占著不放的洩漏。如果每一層在自己的 deadline 到期或例外發生時都能確實釋放已取得的連線與執行緒(把 deadline propagation 與 finally/context manager 的資源釋放綁在一起),這類洩漏就不會有機會隨著失敗率一起指數放大。
對照 Day 10 的 Fault → Error → Failure 鏈條:例外路徑漏放資源是 Fault;資源池被榨乾、新請求拿不到連線是 Error State;使用者端看到大量逾時與服務中斷,是最終的 Failure。66 分鐘裡,真正花時間的不是「找到 bug」,而是「意識到問題不是流量太大,而是每一次失敗都在讓系統變得更脆弱」。
不要先從 Grafana 面板開始。
先寫卡片:
## provider timeout
- 觸發條件:provider 在 250 ms 的 client deadline 內沒有回應。
- Error:`TimeoutError`。
- 使用者影響:`/ask` 不回傳未驗證答案;若沒有合格 cache,回暫時不可用。
- 偵測:`dependency_attempts_total`、`dependency_timeout_total`、
`/ask` 的 P95 latency、trace 的 `dependency.timeout` event。
- 立即減傷:停止新 rollout;保留 interactive request 的容量;
對可重試錯誤只允許指定層的一次 retry。
- 不可做:每一層都 retry;因為不想回 503 而編造 fallback 答案。
- 後續驗證:隔離環境中注入慢回應,確認 error、log、trace、response contract
與 retry attempt 數能彼此對上。
卡片故意不填「根因」。
timeout 可能是 provider 過載、網路路徑、DNS、連線池耗盡、錯誤的 deadline,或自己把 event loop 卡住。事故剛開始時,把猜測寫成事實只會讓人跑錯方向。
| Failure mode | 常見 Fault | 執行期 Error | 使用者可見結果 | 第一個要看的訊號 |
|---|---|---|---|---|
| Crash | 未處理例外、OOM、壞 binary | process exit、container restart | 連線失敗或 5xx | restart count、exit reason、availability |
| Timeout | deadline 過短、dependency 變慢 | deadline exceeded | 慢、503、取消 | timeout ratio、P95/P99、in-flight |
| Network | DNS、TLS、路由、封包遺失 | connect/read failure | 間歇性失敗 | error class、region、dependency health |
| Resource | CPU、RAM、file descriptor、connection pool 耗盡 | queue、GC、reject、OOM | 變慢後失敗 | saturation、queue depth、in-flight |
| Configuration | 壞 endpoint、feature flag、schema 不相容 | validation/authorization error | rollout 後立即或部分失敗 | deploy/flag version、error spike |
| Dependency | provider、database、queue 不可用 | 429、5xx、timeout | 關鍵功能降級或不可用 | dependency status、attempts、fallback ratio |
這六類會重疊。
例如 connection pool 耗盡,表面是「network timeout」,底下其實可能是自己沒有釋放連線,最後把等待中的 request 疊成 resource exhaustion。分類是為了縮小初步調查,不是為了把世界塞進六個抽屜。
除了表格已經寫的內容,有三個地方特別容易被誤判:Network failure 常是間歇性的,同一個 dependency 這次失敗、下次成功,容易被誤判成「dependency 不穩定」——因為它的健康檢查可能完全正常,關鍵訊號在 error class 與地理/機房分布,不在 dependency 的 dashboard。Resource failure 先變慢才失敗,而且會自我加速:排隊中的請求本身也佔用資源,於是資源被吃得更快、回應更慢,形成正回授(Shakespeare Search 案例即是此)。Configuration failure 則是唯一「秒級」發生的一類,不需要流量增長或時間累積,快到無法先觀察一段時間再下結論——下篇會單獨討論。
這是常見的錯誤捷徑:
看到 timeout
↓
調大 client timeout
↓
看起來 error rate 降了
↓
in-flight request 變多
↓
thread / connection / memory 壓力上升
↓
更久以後才失敗,而且一次倒更多
延長 deadline 有時是正確修復,但它不是預設止血法。先確認慢的是哪一段、請求是否還值得完成、以及下游是否有容量,才知道那個 timeout 是保護機制還是錯誤設定。
「延長 deadline 不是預設止血法」容易流於口號,這裡用一組虛構但符合常見數量級的數字把代價講具體。假設一個服務平常每秒處理 100 個請求,provider 平常 P50 延遲 80ms,client timeout 設定 500ms:
正常狀態
每秒 100 個請求,平均每個請求佔用連線 ~150ms(含排隊與處理)
穩態同時在途(in-flight)請求數 ≈ 100 × 0.15 = 15 個
連線池大小設 50,還有充足餘裕
某天 provider 因為自身的 resource 問題變慢,P50 從 80ms 變成 600ms。這時候如果值班人員的第一反應是「把 client timeout 從 500ms 調到 3000ms,先讓 error rate 降下來」:
調大 timeout 之後
每秒仍有 100 個請求進來(使用者行為不會馬上減少)
但現在平均每個請求要多等 2000ms+ 才會有結果(或逾時)
穩態同時在途請求數 ≈ 100 × 2.15 ≈ 215 個
連線池大小仍是 50 → 165 個請求在排隊等連線
error rate 確實會短時間降下來,因為請求不再輕易觸發逾時。但這只是把「立即失敗、使用者立刻知道」換成「排隊等待、系統資源被佔用更久」。流量若沒下降,排隊中的 215 個請求會持續佔用記憶體、連線與執行緒,直到某個資源先被吃光——這正是 Shakespeare Search 案例裡「先變慢,才失敗,而且會自我加速」那句話在數字上的樣貌。
這組數字要傳達的重點不是「timeout 永遠不能調大」,而是調大 timeout 的效果會隨著「下游是否真的有恢復能力」完全不同:下游只是短暫抖動,調大能讓請求撐過那段抖動;下游正在持續退化,調大只是把「使用者立刻看到失敗」的成本,換成「系統資源持續被佔用、直到更大規模崩潰」的成本——後者通常昂貴得多,也更難在事後回推是什麼時候開始惡化的。
第一個情境故意很普通:policy provider 沒有 crash,也沒有回 500,只是慢到超過 client deadline。
這正是 production 最容易被「它還活著」誤導的狀況。
對使用者來說,「服務暫時不能完成」和「服務已經完成」必須可區分:
{
"answer_status": "unavailable",
"technical_success": false,
"degraded": false,
"request_id": "req_...",
"message": "The policy service is temporarily unavailable. Please retry later."
}
不要把上面的 payload 改成:
{
"answer": "我想應該可以遠端三天",
"answer_status": "answered",
"technical_success": true
}
第二個 JSON 讓錯誤離開技術系統,進入使用者的決策流程。那時即使 provider 已經恢復,傷害也不會自動消失。
answer_status這份 payload 刻意放了四個看起來有點重疊的欄位:answer_status、technical_success、degraded、fallback_source(可選)。answer_status: "unavailable" 看似已經足夠表達「這次沒成功」,但「成功」在這裡其實有兩個獨立的軸線,混成一個欄位會讓下游(產品邏輯、前端、甚至下一輪 AI workflow)沒辦法分辨自己拿到的是哪一種:
軸線一:技術上有沒有完成這次呼叫(technical_success)
軸線二:回傳的內容能不能被當作正式答案採信(answer_status / degraded)
四種組合各自代表不同情境:
technical_success=true, degraded=false → 正常成功,可以直接採信
technical_success=true, degraded=true → 用 cache fallback 完成,能用但要標示來源與新鮮度
technical_success=false, degraded=false → 明確失敗,呼叫端知道要走 error handling
technical_success=false, degraded=true → 不應該出現的組合,通常代表程式邏輯寫錯了
如果只用單一欄位表達,例如只回 success: true/false,呼叫端就沒辦法區分「這是正常答案」還是「這是降級後的答案,請自己決定能不能用在這個情境」。對一個會計系統或醫療系統的呼叫端來說,這個區分可能就是能不能把答案直接顯示給使用者的關鍵。
以下程式可放在讀者自己的 Day11/DIY/app/main.py。它只模擬 dependency 的等待,不呼叫外部 API。
import asyncio
import json
import logging
import time
import uuid
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
logger = logging.getLogger("sre_lab")
CLIENT_TIMEOUT_SECONDS = 0.25
class AskRequest(BaseModel):
question: str
failure_mode: str = "normal"
async def policy_provider(mode: str) -> dict[str, str]:
if mode == "slow":
await asyncio.sleep(0.60)
if mode == "unavailable":
raise ConnectionError("simulated provider connection failure")
return {
"answer": "Remote work requires manager approval.",
"source": "company-policy-remote-work",
}
def emit(event: str, **fields: object) -> None:
logger.warning(json.dumps({"event": event, **fields}))
@app.post("/ask")
async def ask(payload: AskRequest) -> dict[str, object]:
request_id = f"req_{uuid.uuid4().hex[:12]}"
started_at = time.monotonic()
try:
result = await asyncio.wait_for(
policy_provider(payload.failure_mode),
timeout=CLIENT_TIMEOUT_SECONDS,
)
except TimeoutError as exc:
elapsed_ms = round((time.monotonic() - started_at) * 1000)
emit(
"dependency_timeout",
request_id=request_id,
dependency="policy_provider",
elapsed_ms=elapsed_ms,
timeout_ms=int(CLIENT_TIMEOUT_SECONDS * 1000),
)
raise HTTPException(
status_code=503,
detail={
"answer_status": "unavailable",
"technical_success": False,
"degraded": False,
"request_id": request_id,
"reason": "policy_provider_timeout",
},
) from exc
elapsed_ms = round((time.monotonic() - started_at) * 1000)
emit(
"dependency_success",
request_id=request_id,
dependency="policy_provider",
elapsed_ms=elapsed_ms,
)
return {
**result,
"answer_status": "answered",
"technical_success": True,
"degraded": False,
"request_id": request_id,
}
asyncio.wait_for() 在這個 Lab 中代表 client deadline。FastAPI route 中以 HTTPException 中止處理並回傳特定 status code,是官方文件支援的 error-handling 方式。FastAPI documentation
asyncio.wait_for(),而不是自己寫一個計時器在 async Python 裡,想替一段協程加上逾時,直覺的做法可能是自己起一個背景計時器,時間到了就設旗標讓主邏輯檢查。問題在於,協程若卡在一個沒有 await 讓出控制權的操作上(同步阻塞呼叫、CPU-bound 迴圈),外部計時器再怎麼設也叫不醒它。
asyncio.wait_for() 做的事情不同:它把目標協程包成一個 Task,時間到了主動對它送出取消(cancel),等取消完成後拋出 TimeoutError。這代表它依賴協程本身有正確回應取消的機制(內部要有 await 點讓 event loop 能介入),也拋出標準函式庫的例外類別供統一捕捉。
如果 provider 呼叫底層做的是同步阻塞 I/O(例如沒有 async 版本的資料庫 driver),asyncio.wait_for() 是叫不動它的——那種情況要把阻塞呼叫丟到 thread pool(asyncio.to_thread() 或 run_in_executor()),讓 event loop 至少還能繼續處理逾時邏輯。這不是本篇要處理的坑,但值得先點出來,免得以為套上 asyncio.wait_for() 就代表所有 timeout 情境都解決了。
這不是 production provider client 的完整實作。
真正的 client 還要處理 connect timeout、read timeout、取消傳播、連線重用、憑證、HTTP status、provider-specific retry hint 與 telemetry。這個範例只保留一件可觀察的事:下游慢於 deadline 時,回應不可以假裝成功。
讀者若已在自己的 DIY 專案安裝 Day 2 使用的 FastAPI 與 Uvicorn,可從 Day11/DIY 啟動:
uv run uvicorn app.main:app --reload --port 8000
另一個 terminal 可送正常情境:
curl -i http://127.0.0.1:8000/ask \
-H 'content-type: application/json' \
-d '{"question":"Can I work remotely?"}'
再送慢 provider 情境:
curl -i http://127.0.0.1:8000/ask \
-H 'content-type: application/json' \
-d '{"question":"Can I work remotely?","failure_mode":"slow"}'
預期可觀察結果不是「curl 有沒有輸出」。
正常請求應有 200、answer_status: "answered" 與一筆 dependency_success log。
慢請求應在約 250 ms 後有 503,回應 body 的 answer_status 為 unavailable,並有一筆 dependency_timeout log。實際時間受本機排程影響;不要把這個 Lab 的毫秒數寫進 production SLO。
每一筆事件至少要能用 request_id 對起來:
Metric
dependency_timeout_total{dependency="policy_provider"} +1
Log
event=dependency_timeout request_id=req_a1b2... elapsed_ms=251
Trace
span=policy_provider status=ERROR
event=dependency.timeout timeout_ms=250 request_id=req_a1b2...
Prometheus label 不要放 request_id、問題全文或 provider response。那些高基數、可能敏感的欄位應留在 trace 或有存取控管的 log;metric 只保留能聚合的 dependency、reason、outcome 等低基數維度。
若讀者沿用 Day 2 的 OpenTelemetry,span 可以長成:
POST /ask
└── policy_provider
├── dependency.name = policy_provider
├── failure.mode = timeout
├── retry.attempt = 0
└── event: dependency.timeout
trace 不是拿來取代 metric。
metric 告訴你 timeout ratio 是否擴大;trace 讓你選一筆 request 追 deadline 花在哪裡;log 則保留錯誤 class 與足以還原當時決策的欄位。
這個問題值得直接回答,因為省掉其中一種在短期內看起來完全沒差——直到事故發生的那一刻才會發現代價。
只有 metric,沒有 log/trace
能知道:「timeout 變多了」
不能知道:哪一筆 request、卡在哪個環節、具體的錯誤是什麼
只有 log,沒有 metric/trace
能知道:某一筆 request 發生了什麼
不能知道:這件事的規模有多大,是單一事件還是系統性問題,
也難以設定告警門檻(log 通常不適合拿來算 ratio)
只有 trace,沒有 metric/log
能知道:抽樣到的那幾筆 request 內部發生了什麼
不能知道:整體趨勢(trace 通常有取樣率,不是每筆都留),
也難以在事故發生的當下第一時間就被觸發告警
三種資料能互補,關鍵在於它們各自最適合回答的問題不一樣:metric 適合回答「有多嚴重、告警要不要響」;log 適合回答「這一類事件的細節欄位是什麼」;trace 適合回答「時間都花在哪裡、呼叫鏈上哪一段最慢」。三者能用同一個 request_id(或 deployment_id、trace_id 這類關聯欄位)互相串起來,才能先用 metric 確認規模、用 log 確認變更時間線、再用 trace 鎖定根因所在的那一個 span;少了任何一種,中間就會出現一段只能用猜的空白。
200 OK有些系統會在 catch block 裡回:
return {
"answer": "No answer was generated.",
"answer_status": "answered",
"technical_success": True,
}
這讓 HTTP error rate 看起來更漂亮,卻破壞 response contract。
如果產品要提供 fallback,就要把 fallback 寫成產品能力,而不是 exception handler 的遮羞布:標示 degraded: true、提供可追查來源、定義新鮮度上限,並量測 degraded ratio。
這種「把失敗藏進 200 OK」的衝動,往往不是出於惡意,而是出於一種可以理解的直覺:dashboard 上的紅色很刺眼,工程師看到 error rate 飆升會下意識想讓它變綠。但這正是 Day 7 那句話真正要提醒的:HTTP 200 從來就不是「回答可用」的證明,它只是「這次呼叫沒有在協定層面出錯」的證明。把 provider timeout 包裝成一段「無答案內容」的 200 回應,改變的只是 dashboard 的顏色,卻讓下一個看到這份回應的系統(前端,或另一個 AI workflow 的下一步)失去了「這次結果不可信」的關鍵訊號。這條界線在 Day 12 會被放大檢視。
第二個情境把 provider 改成明確不可用。
直覺做法通常是:
失敗
↓
立刻再送一次
↓
還失敗
↓
再送一次
單一請求看起來很無害。
問題從併發開始。
假設 20 個使用者同時點擊,每層各自做「第一次加三次 retry」:
Browser: 4 attempts
↓
API gateway: 4 attempts
↓
Application client: 4 attempts
↓
最多 4 × 4 × 4 = 64 次 downstream attempt / 一次使用者動作
20 個使用者的 20 次動作,在極端情況會把 1,280 次 attempt 丟給已經不健康的 dependency。
這是上限模型,不代表每次都會剛好產生 64 次呼叫。它的用途是逼團隊回答一個更實際的問題:retry 的唯一擁有者是哪一層?
Google SRE Book 以多層 retry 的乘法效應說明級聯失敗;AWS 也建議在重試時使用 capped exponential backoff 與 jitter,避免同一批 client 同時醒來再次打爆服務。Google SRE Book AWS Builders' Library
以下是不要帶進 production的示意:
async def wrong_retry(call):
for _ in range(4):
try:
return await call()
except Exception:
await asyncio.sleep(0.05)
raise RuntimeError("provider failed")
它的問題不是只有 except Exception 太寬。
問題一:把 schema error、401、400 也當成可重試。
問題二:所有 client 在 50 ms 後一起重送。
問題三:沒有 absolute deadline,可能耗盡使用者可等待時間。
問題四:沒有全域或 process-level retry budget。
問題五:呼叫端不知道其他層也在 retry。
「多試幾次」不是可靠性設計。它只是把原本一次的失敗,改成多次的負載。用本系列慣用的對比表示:
一次失敗的請求
+
沒有協調的多層 retry
=
多次失敗的請求,而且是同時發生的多次失敗
「重試讓系統更可靠」
≠
「重試讓某一次的失敗,變成一段時間內的重複失敗」
前者是重試機制被引入時最初的期望;後者是它在缺乏 retry owner、budget、跨層協調時的真實效果。差別不在重試這個動作本身,而在有沒有被設計成一個有邊界、有歸屬的機制。
問題一點出的「把 schema error、401、400 也當成可重試」,是最常見、也最容易被忽略的一種寫法。原因通常不是工程師不懂這個道理,而是 except Exception 這種寫法在開發階段很方便——先求能動,retry 邏輯之後再收斂,結果「之後」常常沒有發生。
判斷一個錯誤能不能重試,關鍵不是它的 HTTP status code 屬於哪個區間,而是「重試這個動作,有沒有機會讓結果不一樣」:
| Error 類型 | 範例 | 重試有幫助嗎 | 理由 |
|---|---|---|---|
| 連線層暫時失敗 | ConnectionError、connection reset |
通常有 | 下一次連線可能走到不同的健康後端 |
| Timeout | TimeoutError |
視情境 | 若 dependency 只是暫時慢,有機會;若已持續過載,重試只會加重負擔 |
| Rate limit | HTTP 429 | 有,但要照對方要求的節奏 | 對方明確表示「現在不要送」,立刻重送等於忽視訊號 |
| Server error | HTTP 5xx | 視情境 | 可能是暫時的 infra 問題,也可能是穩定的 bug,重試前要看歷史發生率 |
| 請求格式錯誤 | HTTP 400 | 幾乎不會 | 請求內容本身有問題,原封不動送第二次,結果必然相同 |
| 認證/授權失敗 | HTTP 401 / 403 | 不會 | 憑證或權限問題不會因為多送一次而消失 |
| 業務邏輯拒絕 | HTTP 404、422 | 不會 | 對方已經明確判斷這個請求不成立 |
這張表最重要的分界線在「請求本身有沒有問題」跟「這次執行環境有沒有問題」之間。4xx 系列的錯誤,絕大多數是在告訴你「你送的東西本身就不對」——重試不會讓格式錯誤的 JSON 突然變合法,也不會讓過期的 token 突然變有效。把這類錯誤丟進 retry loop,唯一的效果就是把一次確定會失敗的請求,變成好幾次確定會失敗的請求,而且每一次都消耗真實的運算與金錢成本。
這正是下一個案例發生的方式。有工程團隊在事後分析中公開了一次 AI Agent 的重試事故:下游服務因為一次 schema 變更,把原本選填的欄位改成必填,導致特定形狀的請求開始固定回傳 HTTP 400 Bad Request。
問題出在 Agent 的重試邏輯沒有做前面那張表的區分:
下游 schema 變更,某類請求開始固定回 400
↓
Agent 的 retry 邏輯只看「有沒有例外」,不看「例外的種類」
↓
把 400 當成暫時性錯誤,套用 exponential backoff 重試
↓
每次重試間隔變長,但因為 Agent 持續產生新的類似請求,
retry worker 的數量並未跟著減少
↓
多個 worker 對著同一種「注定失敗」的請求形狀,反覆重試數小時
↓
單一 Agent 在這個過程中消耗約 200 美元的 API 費用,
且下游服務即使早已修復,這個 Agent 仍在對著舊的錯誤請求重試
這裡最值得抽出來的教訓,不是「200 美元很多」,而是最後一句:即使根因已經修好,retry storm 本身會變成一個新的、獨立的 failure mode,而且它不會自己停下來。因為 Agent 判斷「要不要繼續重試」的依據從頭到尾都只有「上次有沒有成功」,而不是「這個請求形狀本身有沒有機會成功」——重試邏輯需要知道的不只是「發生了 error」,還要知道「這個 error 的種類是否值得再花一次代價去嘗試」。
對照本文情境二的 is_retriable() 函式,它只允許 ConnectionError 與 TimeoutError 進入重試路徑,其餘一律直接拋出——這正是為了避免上面這種情境發生。如果把 is_retriable() 換成永遠回傳 True,這段程式碼在遇到持續回傳 400 的 dependency 時,行為會跟這個案例完全一樣。
這個 Lab 的選擇很保守:只有 application 的 provider client 可以 retry;最多一次;只有暫時性連線錯誤;browser 與 gateway 不再替同一個 /ask retry。
import asyncio
import random
import time
MAX_RETRIES = 1
BASE_BACKOFF_SECONDS = 0.05
MAX_BACKOFF_SECONDS = 0.20
def is_retriable(error: Exception) -> bool:
return isinstance(error, (ConnectionError, TimeoutError))
def backoff_with_jitter(attempt: int) -> float:
ceiling = min(
MAX_BACKOFF_SECONDS,
BASE_BACKOFF_SECONDS * (2 ** attempt),
)
return random.uniform(0, ceiling)
async def call_provider_with_budget(mode: str) -> dict[str, str]:
deadline = time.monotonic() + 0.80
for attempt in range(MAX_RETRIES + 1):
try:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("request deadline exhausted")
return await asyncio.wait_for(
policy_provider(mode),
timeout=min(CLIENT_TIMEOUT_SECONDS, remaining),
)
except Exception as exc:
if not is_retriable(exc) or attempt == MAX_RETRIES:
raise
delay = backoff_with_jitter(attempt)
if time.monotonic() + delay >= deadline:
raise TimeoutError("no time left for retry") from exc
emit(
"dependency_retry_scheduled",
dependency="policy_provider",
retry_attempt=attempt + 1,
backoff_ms=round(delay * 1000),
)
await asyncio.sleep(delay)
raise AssertionError("unreachable")
backoff_with_jitter() 沒有直接回傳 BASE_BACKOFF_SECONDS * (2 ** attempt),而是用 random.uniform(0, ceiling) 在 0 到上限之間隨機取值,用來解決一個具體問題:假設沒有 jitter,20 個併發請求同時遇到 provider 逾時,backoff 邏輯完全相同,就會在完全相同的時間點一起重送:
沒有 jitter
t=0ms 20 個請求同時失敗
t=50ms 20 個請求同時重送 ← 又是一次同時打擊
t=150ms 20 個請求(若仍失敗)又同時重送
有 jitter(0 到 ceiling 之間均勻分布)
t=0ms 20 個請求同時失敗
t=3~50ms 之間 20 個請求分散在這段區間各自重送 ← 尖峰被打散成連續的小波
這正是 AWS Builders' Library 那篇文章的核心論點:client 集體重試,對 dependency 來說就像週期性的流量尖峰,比穩定的高流量更難處理,因為容量規劃是照平均值或平緩的成長曲線做的,不是照瞬間脈衝做的。加了 jitter,同樣數量的重試請求被攤開在一段時間內送達,dependency 承受的是連續、可預期的負載曲線。
min(MAX_BACKOFF_SECONDS, BASE_BACKOFF_SECONDS * (2 ** attempt)) 做的是指數成長加上上限:每多重試一次基礎等待時間就翻倍,但 MAX_BACKOFF_SECONDS = 0.20 保證單次等待不會超過 200ms——這個上限跟 deadline propagation 同一個道理:backoff 沒有上限,可能還沒重試到第二次就已經吃光使用者願意等待的全部時間。
這裡仍然沒有 production 的 retry budget implementation。
MAX_RETRIES = 1 是單一 request 的上限,不是「整個 service 每分鐘只重試一次」。production 若流量較大,還要有 service-level retry budget、circuit breaker 或 admission control,讓大量同時失敗的請求不能無限生出新嘗試。
要判斷 retry 是幫忙還是添亂,至少需要:
dependency_attempts_total{
dependency="policy_provider",
outcome="success|timeout|connection_error",
retry="true|false"
}
dependency_retry_scheduled_total{
dependency="policy_provider"
}
dependency_retry_exhausted_total{
dependency="policy_provider"
}
ask_requests_in_flight
ask_degraded_total{
reason="provider_unavailable"
}
一個簡單的 investigation 順序:
1. `/ask` 失敗率是否上升?
2. dependency attempt rate 是否比 logical request rate 增長更快?
3. retry=true 的 attempts 比例是否跳升?
4. in-flight request 是否累積?
5. provider 端 latency / 429 / 5xx 是否也變差?
6. 停止 retry 或啟用 degraded mode 後,attempt rate 是否先下降?
第 2 點很重要。
如果每秒 100 個 logical request,卻看到每秒 260 次 provider attempt,系統沒有多出 160 位使用者。它可能正在對自己的失敗做正回授。
讀者可把 /ask handler 中的單次 policy_provider(...) 呼叫替換為 call_provider_with_budget(...),再送出模擬不可用的請求:
curl -i http://127.0.0.1:8000/ask \
-H 'content-type: application/json' \
-d '{"question":"Can I work remotely?","failure_mode":"unavailable"}'
接著在隔離環境才考慮用有限併發重複送請求,例如以既有的 load tool 或 shell loop 產生少量請求。先從 5 個開始,不要把自己的 laptop 當 DDoS 練習場。
此情境的預期不是「全部成功」。provider 被刻意設成不可用,合理結果是有限次 attempt 後回 503。應觀察到:
每個 logical request:最多 2 次 provider attempt
retry attempt:有不同的 jitter delay,不全在同一時間點發生
回應:不回傳假答案
log / trace:能看到 retry_attempt 與最終 error class
metric:attempt rate 與 logical request rate 的差距可計算
如果你把錯誤版本的 wrong_retry() 換進去,請只在隔離環境比較。它的價值是讓你從 evidence 看見「retry rate 上升」不只是圖表不好看,而是服務把更多工作交給已經無法服務的 dependency。
這不是理論。2026 年 8 月 17 日,GitHub 經歷 7 小時 47 分鐘的全面故障,起因是流量攀上新高峰時,一個關鍵基礎設施元件沒有跟著擴容:Istio sidecar pod 因為擴容設定只監看 host service、沒監看 sidecar 本身併發上限,沒能正確 autoscale。這本身還只是一次 resource / configuration failure,真正把事故拖長的是接下來的 retry storm——VS Code 客戶端一個既有的重試邏輯缺陷在延遲升高時被觸發,把打向 Copilot Token Service 的流量放大約 10 倍:從平常的 7,000–9,000 req/sec 衝到 70,000–100,000 req/sec,把已經在復原路上的服務再次打趴。GitHub 事後的改進方向包括修正 autoscaling policy、檢討 retry 上限、稽核 Istio 併發設定,以及處理那段 VS Code 行為。如果每層都獨立決定「我會 retry」,結果往往是集體自殺。
當 provider 不可用,系統通常有三種選擇:
A. 繼續嘗試直到成功
B. 明確拒絕
C. 做較少、但仍符合產品合約的工作
A 很容易變成 retry storm。
B 有時完全正確,尤其是付款、寫入、權限與需要即時資料的流程。
C 才是 graceful degradation,但前提很嚴格:你要能說清楚少做了什麼、資訊從哪裡來、資料有多新,以及使用者不能拿它做什麼。
假設「遠端工作規範」有一份版本化、可驗證、一天內更新過的唯讀快取。
{
"answer": "Remote work requires manager approval.",
"answer_status": "answered",
"technical_success": true,
"degraded": true,
"fallback_source": "policy-cache-v42",
"fallback_age_seconds": 420,
"request_id": "req_..."
}
這不是正常成功。
它是「在已知限制下,提供仍符合合約的較小能力」。因此 dashboard 要能看到 degraded,值班人員要有 cache freshness 的警報條件,產品也必須接受這段內容可能不是最新規範。
「可接受的 fallback」跟「不能接受的 fallback」之間,最容易被忽略的一條界線是資料新鮮度。一份快取如果沒有明確的保鮮期設計,provider 剛好變慢的那一刻,跟 provider 已經連續故障三天、快取早就過期的那一刻,回傳的都會是同一段內容——呼叫端沒辦法從回應本身分辨「三分鐘前更新」還是「三天前更新、可能早就不適用」。
一個簡單但足夠務實的保鮮期設計,通常需要三個門檻,而不是只有「有快取」與「沒快取」兩種狀態:
FRESH_THRESHOLD_SECONDS = 300 # 5 分鐘內:視為新鮮,可直接當 degraded fallback 使用
STALE_THRESHOLD_SECONDS = 3600 # 1 小時內:仍可用,但要更明確地標示風險
EXPIRED_THRESHOLD_SECONDS = 3600 # 超過 1 小時:不再當作合格 fallback,直接視為沒有 fallback
def evaluate_cache_freshness(cache_age_seconds: float) -> str:
if cache_age_seconds <= FRESH_THRESHOLD_SECONDS:
return "fresh"
if cache_age_seconds <= STALE_THRESHOLD_SECONDS:
return "stale"
return "expired"
def build_response(cache_entry: dict, cache_age_seconds: float) -> dict:
freshness = evaluate_cache_freshness(cache_age_seconds)
if freshness == "expired":
# 過期的快取不能被當成合格 fallback,等同於「沒有 fallback」,
# 應該走回④的明確 503 路徑,而不是硬塞一份舊資料充數。
raise RuntimeError("cache expired, no qualified fallback available")
return {
"answer": cache_entry["answer"],
"answer_status": "answered",
"technical_success": True,
"degraded": True,
"fallback_source": cache_entry["source"],
"fallback_age_seconds": round(cache_age_seconds),
"fallback_freshness": freshness, # "fresh" 或 "stale",讓呼叫端自己決定要不要採信
}
重點不是這三個門檻的具體秒數(那要看業務對「多舊算舊」的容忍度),而是「過期」本身要有明確的判斷邊界,一旦跨過,系統應該退回④講的明確拒絕,而不是「反正有資料就給」。fallback_freshness 放進回應裡,是讓呼叫端自己決定:stale 的答案可以顯示但要加風險提示,fresh 的可以直接當正常結果處理。
這個保鮮期設計本身也要納入 failure-mode card:快取多久沒更新算異常、誰負責監控快取的更新流程、快取來源掛掉要不要另外告警——fallback 機制本身也是一個需要被同樣嚴謹對待的 dependency。
provider timeout
↓
改用無來源的 LLM 回答人資規範
↓
使用者得到流暢、但無法確認的答案
這不是 degradation,是把 dependency failure 轉成 Day 12 要處理的 semantic failure。
同樣地,寫入操作不能因為 upstream timeout 就「猜測已成功」。例如建立訂單、扣款、發送通知等流程需要 idempotency、狀態查詢或人工確認;這些不是本篇的 cache fallback 能解決的問題。
以下任一條成立時,明確拒絕常比排隊等待好:
- 使用者 deadline 已經快耗盡。
- 服務的 queue / in-flight requests 高於已知安全門檻。
- dependency 已經回報 overload,且 retry budget 已用完。
- fallback 資料過期或不適用於這個任務。
- 請求會改變外部狀態,無法安全重試或回放。
503 不是漂亮的產品畫面,但它是有用的協定訊號。比起讓請求在 queue 裡死撐 20 秒、最後才丟 500,它保留了系統恢復的空間,也告訴 client 這次沒有完成。
上面「dependency 已經回報 overload,且 retry budget 已用完」這條,講的其實是 circuit breaker。它不是一句空話,而是一個具體、可實作的狀態機:
closed(正常放行請求)
↓ 失敗率或 timeout 比例超過門檻
open(直接拒絕,不再呼叫這個 dependency)
↓ 冷卻時間過後
half-open(只放行少量請求探路)
├── 探路成功 → 回到 closed
└── 探路仍失敗 → 回到 open,冷卻時間可以拉更長
retry budget 與 circuit breaker 是兩層互補的煞車:前者限制單一 request 能重試幾次,後者限制整個 process 要不要繼續嘗試同一個 dependency。少了 circuit breaker,即使前一毫秒才有一百個 request 各自用光 retry budget、確認了 dependency 目前無法服務,下一個 request 依然要重新走一遍同樣的流程才能得出同樣的結論;有了 circuit breaker,它記得「最近已經確認過很多次了」,新進來的 request 可以直接被快速拒絕。open 狀態的意義不是「我猜它掛了」,而是主動停止對一個已知不健康的 dependency 施壓,把原本會浪費在等待與重試上的資源,讓給還有機會成功的請求。
門檻怎麼訂沒有放諸四海皆準的數字,但至少要能回答三個問題:多少比例的失敗算「不健康」、觀察窗口多長、冷卻多久才值得再試一次。訂太敏感,連暫時的抖動都會把健康的 dependency 誤判成故障;訂太遲鈍,circuit breaker 形同虛設,情境二講的 retry amplification 一樣會發生。這組數字跟 client deadline 一樣,屬於「Lab 假設,不是 production 建議值」,要照實際的失敗率分布與復原時間反覆調整。
circuit breaker 常被講得很抽象,這裡給一個刻意精簡、只保留核心狀態轉移的骨架,幫助把上面那張狀態圖對應回實際程式碼(這不是給 /ask 用的完整實作,只是示意骨架):
import time
class CircuitBreaker:
def __init__(self, failure_threshold: float, window_seconds: float,
cooldown_seconds: float):
self.failure_threshold = failure_threshold
self.window_seconds = window_seconds
self.cooldown_seconds = cooldown_seconds
self.state = "closed"
self.opened_at: float | None = None
self.recent_outcomes: list[tuple[float, bool]] = [] # (timestamp, success)
def _failure_rate(self) -> float:
now = time.monotonic()
window = [ok for ts, ok in self.recent_outcomes if now - ts <= self.window_seconds]
if not window:
return 0.0
failures = sum(1 for ok in window if not ok)
return failures / len(window)
def allow_request(self) -> bool:
if self.state == "open":
if time.monotonic() - self.opened_at >= self.cooldown_seconds:
self.state = "half_open"
return True
return False
return True
def record_outcome(self, success: bool) -> None:
self.recent_outcomes.append((time.monotonic(), success))
if self.state == "half_open":
self.state = "closed" if success else "open"
if self.state == "open":
self.opened_at = time.monotonic()
return
if self.state == "closed" and self._failure_rate() >= self.failure_threshold:
self.state = "open"
self.opened_at = time.monotonic()
allow_request() 對應狀態圖裡「要不要放行這次呼叫」的判斷;record_outcome() 則根據探路結果決定回到 closed 還是重新 open。這個骨架省略了執行緒安全、滑動窗口的高效實作與 metric 整合,真正拿去用之前都要補齊,但它足以讓「circuit breaker 是一個狀態機,不是一個 if 判斷式」變得具體可讀。
circuit breaker 保護的是已知不健康的「下游」:偵測到某個 dependency 失敗率過高,就主動停止呼叫它,好比發現這艘船已經在漏水,乾脆別再往那個艙室送補給。bulkhead(艙壁隔離)保護的則是呼叫端「自己」的資源:把不同 dependency 的連線池、執行緒池、併發配額分開,避免其中一個出問題時把整個 process 的資源都佔滿,好比把船體切成互不相通的艙室,一個艙室進水不會讓整艘船沉。
兩者通常要搭配使用,而不是二選一:/ask 若同時呼叫 policy provider 與另一個 embedding 服務、共用同一個連線池,policy provider 卡住時連 embedding 服務都可能拿不到連線——即使它本身完全健康。資源耗盡的根因,經常是另一個 dependency 的問題外溢出來的。
下篇談 configuration failure(最快發生也最容易被誤診)、把六種死法收斂成一條可執行的調查流程,以及今天的 DIY 練習。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.