iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 12 篇

Day 11(上)|Production Failure Modes:先替系統想好難看的死法

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說: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、安裝套件、啟動服務、發出請求或驗證結果。

① Failure mode 不是 alert 名稱

「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,使用者結果可以不同

執行期 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,觸發時我們知道要看什麼、做什麼嗎」——答不出來,它就還是個未完成的設計。

② 先替 critical path 畫出失敗傳播

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 是「剩餘預算」,不是「固定時長」

這是 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 Shakespeare Search 的 66 分鐘資源洩漏

Google SRE Book 記錄過一次很適合放在這裡的案例:內部的 Shakespeare Search 服務在一次流量異常時中斷了 66 分鐘,根因不是外部攻擊,而是自己的例外處理路徑沒有把資源釋放乾淨。

事故的鏈條大致是這樣:

一小部分搜尋請求開始失敗
  ↓
失敗路徑上,連線與執行緒沒有被正確釋放(資源洩漏)
  ↓
正常流量持續進來,每次失敗都再洩漏一點資源
  ↓
連線池、執行緒池被慢慢吃光
  ↓
新的請求連取得資源都做不到,開始大量逾時
  ↓
逾時本身又提高了失敗率,回到第一步,形成正回授迴圈

這個案例示範了「deadline 沒有被正確遵守」的另一種樣貌:不是請求真的需要那麼久,而是失敗路徑上的清理工作沒有在該結束的時候結束,讓一個原本只該存在一次呼叫份量的資源占用,變成長期占著不放的洩漏。如果每一層在自己的 deadline 到期或例外發生時都能確實釋放已取得的連線與執行緒(把 deadline propagation 與 finally/context manager 的資源釋放綁在一起),這類洩漏就不會有機會隨著失敗率一起指數放大。

對照 Day 10 的 Fault → Error → Failure 鏈條:例外路徑漏放資源是 Fault;資源池被榨乾、新請求拿不到連線是 Error State;使用者端看到大量逾時與服務中斷,是最終的 Failure。66 分鐘裡,真正花時間的不是「找到 bug」,而是「意識到問題不是流量太大,而是每一次失敗都在讓系統變得更脆弱」。

failure-mode card:先寫一句可驗證的敘述

不要先從 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 都當網路問題

這是常見的錯誤捷徑:

看到 timeout
  ↓
調大 client timeout
  ↓
看起來 error rate 降了
  ↓
in-flight request 變多
  ↓
thread / connection / memory 壓力上升
  ↓
更久以後才失敗,而且一次倒更多

延長 deadline 有時是正確修復,但它不是預設止血法。先確認慢的是哪一段、請求是否還值得完成、以及下游是否有容量,才知道那個 timeout 是保護機制還是錯誤設定。

用一組虛構但具體的數字看清楚「調大 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 的效果會隨著「下游是否真的有恢復能力」完全不同:下游只是短暫抖動,調大能讓請求撐過那段抖動;下游正在持續退化,調大只是把「使用者立刻看到失敗」的成本,換成「系統資源持續被佔用、直到更大規模崩潰」的成本——後者通常昂貴得多,也更難在事後回推是什麼時候開始惡化的。

④ 情境一:慢 dependency 與 deadline

第一個情境故意很普通:policy provider 沒有 crash,也沒有回 500,只是慢到超過 client deadline。

這正是 production 最容易被「它還活著」誤導的狀況。

先定義 response contract

對使用者來說,「服務暫時不能完成」和「服務已經完成」必須可區分:

{
  "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 已經恢復,傷害也不會自動消失。

為什麼 response contract 要包含這四個欄位,不能只回 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,呼叫端就沒辦法區分「這是正常答案」還是「這是降級後的答案,請自己決定能不能用在這個情境」。對一個會計系統或醫療系統的呼叫端來說,這個區分可能就是能不能把答案直接顯示給使用者的關鍵。

Step 1:放入可控制的慢 provider

以下程式可放在讀者自己的 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 時,回應不可以假裝成功。

Step 2:在隔離環境發出兩個請求

讀者若已在自己的 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。

Step 3:把同一事件放進三種觀測資料

每一筆事件至少要能用 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;少了任何一種,中間就會出現一段只能用猜的空白。

反例:把 timeout 藏在 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 會被放大檢視。

⑤ 情境二:retry amplification 不是多試幾次而已

第二個情境把 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

Step 1:先寫錯誤版本,才知道它錯在哪裡

以下是不要帶進 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、跨層協調時的真實效果。差別不在重試這個動作本身,而在有沒有被設計成一個有邊界、有歸屬的機制。

retriable ≠ 「發生了錯誤」,non-retriable ≠ 「發生了 5xx」

問題一點出的「把 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,唯一的效果就是把一次確定會失敗的請求,變成好幾次確定會失敗的請求,而且每一次都消耗真實的運算與金錢成本。

業界實例:一個 Agent 花 200 美元重試一個注定失敗的請求

這正是下一個案例發生的方式。有工程團隊在事後分析中公開了一次 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 時,行為會跟這個案例完全一樣。

Step 2:指定 retry owner 與可重試條件

這個 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")

為什麼要 jitter,而不是單純的 exponential backoff

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,讓大量同時失敗的請求不能無限生出新嘗試。

Step 3:替 retry 加上可觀察 evidence

要判斷 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 位使用者。它可能正在對自己的失敗做正回授。

Step 4:在隔離環境觀察 amplification

讀者可把 /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。

業界實例:GitHub 2026 年 8 月 retry storm

這不是理論。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」,結果往往是集體自殺。

⑥ Degradation 是減少工作,不是偷偷降低事實標準

當 provider 不可用,系統通常有三種選擇:

A. 繼續嘗試直到成功
B. 明確拒絕
C. 做較少、但仍符合產品合約的工作

A 很容易變成 retry storm。

B 有時完全正確,尤其是付款、寫入、權限與需要即時資料的流程。

C 才是 graceful degradation,但前提很嚴格:你要能說清楚少做了什麼、資訊從哪裡來、資料有多新,以及使用者不能拿它做什麼。

一個可接受的 policy fallback

假設「遠端工作規範」有一份版本化、可驗證、一天內更新過的唯讀快取。

{
  "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」跟「不能接受的 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。

一個不能接受的 fallback

provider timeout
  ↓
改用無來源的 LLM 回答人資規範
  ↓
使用者得到流暢、但無法確認的答案

這不是 degradation,是把 dependency failure 轉成 Day 12 要處理的 semantic failure。

同樣地,寫入操作不能因為 upstream timeout 就「猜測已成功」。例如建立訂單、扣款、發送通知等流程需要 idempotency、狀態查詢或人工確認;這些不是本篇的 cache fallback 能解決的問題。

何時應該 fail fast

以下任一條成立時,明確拒絕常比排隊等待好:

- 使用者 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 骨架

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 ≠ bulkhead,兩者保護的對象不同

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.


上一篇
Day 10(上)|Fault、Error、Failure:別把警報名稱當成根因
下一篇
Day 10(下)|Fault、Error、Failure:別把警報名稱當成根因
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言