問題

在一個 FastAPI 原型專案補限流時,把 Redis 當 storage,並要求「storage 掛掉要 fail-open(放行)」。元件層測試全綠、677 支測試全過、lint 與型別檢查全綠。

但實際停掉 Redis 去打端點:

一般列表端點(只走 middleware)   -> 200   ← 對的
登入端點(走限流裝飾器)          -> 500   ← 假綠燈

登入端點整支掛掉(這個 500 已經修掉,下面會講怎麼修)。諷刺的是,登入爆破防護刻意做成 fail-open,就是為了 Redis 掛掉時登入還能用——結果反而比補限流之前更糟。

方向本身沒選錯。照 fail-open/fail-closed 的判準,rate limit 和登入爆破計數擋的都是「量」,故障時放行的代價只是那段時間限制放寬,所以 fail-open 是對的方向。出事的是「說好的 fail-open 實際上沒有發生」。

根因

兩層,第二層才是這篇的重點。

第一層:套件自己的 fail-open 是假的。

用的是 slowapi 0.1.9。它的 swallow_errors=True 會吞掉 storage 故障:限流檢查在 __evaluate_limits() 裡呼叫 storage,storage 拋例外,_check_request_limit() 的 except 分支記一筆 log 就放過去。問題是 request.state.view_rate_limit 是在 __evaluate_limits() 的最後一行才設定的,storage 一拋例外,那行就沒跑到。但套件自己的裝飾器 wrapper 接著無條件讀那個屬性:

# slowapi 0.1.9, slowapi/extension.py:739(async_wrapper)
kwargs.get("response"), request.state.view_rate_limit
# → AttributeError: 'State' object has no attribute 'view_rate_limit'

所謂 fail-open,實際上是 fail-500。

上游現況(2026-10-11 查):slowapi 最新版是 0.1.10(2026-06 發布),release notes 沒有提到這個行為;對照 0.1.9 與 0.1.10/main 的 extension.py,「例外被吞掉 → view_rate_limit 沒設 → wrapper 無條件讀」這三段程式碼的結構沒變,所以 0.1.10 應該仍有同樣的洞(未查證:沒有在 0.1.10 上實際重跑)。上游 issue 以 view_rate_limit、swallow_errors 搜尋,沒找到針對這個組合的回報;#114 是同一個 AttributeError 字樣,但觸發原因不同。

另外,slowapi 文件寫的 swallow_errors 語意是「吞掉錯誤,仍會記 log」,本來就沒有保證後續流程不出事。套件另有 in_memory_fallback 選項(storage 掛掉時改用行程內記憶體計數),這個專案沒有採用,它是否能避開這個洞也未查證。

第二層:修的人只修了一條路徑。

這個洞有兩條路徑:自寫的 middleware,和套件內建的 @limiter.limit 裝飾器。middleware 那條被繞過去了(改成「有值才注入 header」),裝飾器那條繞不到,因為它跑在套件自己的 wrapper 裡。全專案只有一個端點掛裝飾器,就是登入。

為什麼測試沒抓到:條件式觸發。

async_wrapper 的順序是先執行端點函式、再讀限流狀態注入 header。帳密錯誤時端點直接拋 AuthenticationError,讀屬性那一行根本跑不到——所以這個 bug 只在登入成功路徑觸發。第一版回歸測試用錯誤帳密寫,跑出來是綠的:

test_login_does_not_500                  PASSED   ← 用錯誤帳密
test_login_response_is_unified_envelope  FAILED   ← 同一個呼叫、不同斷言

同一個請求、兩個斷言結果相反,才逼出「只在成功路徑觸發」這件事。

解法

  1. _ensure_view_rate_limit(request):缺該屬性就補 None。slowapi 的 _inject_headers 在 current_limit is None 時本來就什麼都不做,所以補 None 的語意是「這次沒有可回報的限流狀態」,不是把例外掩蓋掉。
  2. 自家的 rate_limit() 裝飾器包住 limiter.limit(),進套件 wrapper 前先補值。保證放在裝飾器身上,新端點只要用它就自帶 fail-open,不必知道 middleware 存不存在、掛在哪一層。
  3. middleware 的 dispatch 最前面也補一次,早於所有提早返回的分支(豁免路由、限流關閉、被 _should_exempt 跳過的路由)。兩層都做是刻意的 defence in depth。
  4. AST 守門測試禁止任何模組直接用 @limiter.limit 或 @limiter.shared_limit(後者走同一個 __limit_decorator,同樣會中),唯一豁免是包裝它們的那個模組。

修完之後,同樣停掉 Redis 的序列重打一次,登入回 200。

為什麼這樣解

把保證放在裝飾器而不是文件或 code review 上,是因為這個洞的特性是「新增一個端點就可能重蹈覆轍」。守門測試讓違規在 CI 就紅,而不是等 Redis 掛掉才發現。

守門測試的形式也講究:assert offenders == [] 配 AST 掃描,注入一個違規檔就會紅——這是「重構後仍會紅」的形式,而且實際注入違規檔反向驗證過它會紅。反例是 assert.NotNil(常數) 這種斷言:常數永遠不是 nil,改了實作也不會紅,等於沒有守門。

怎麼預防再次發生

這條是通用的,不限 slowapi、不限 Python:

  1. 降級策略(fail-open/fail-closed)的驗證,必須在依賴實際停機的狀態下打真實端點,元件層測試不算數。fail-open 的基本要求之一是「不得崩潰」,而元件層測試證不到「整條請求鏈不崩潰」。登入這類未認證路徑前面沒有 fail-closed 的關卡,停真 Redis 實打是有效的量法;認證後的元件要改用選擇性故障注入,見判準那篇的「量測陷阱」。
  2. 窮舉受影響的路徑,不要抽樣。 同一個機制常常有多條接線方式(middleware/裝飾器/手動呼叫),套件只保證它自己那條。修的時候要問「這個機制總共有幾個入口」。
  3. 降級測試要涵蓋成功路徑,不能只測失敗路徑。 錯誤處理的程式碼常常在「提早返回」時跳過後續邏輯,於是 bug 只在 happy path 觸發——直覺上剛好相反。
  4. 驗收看的是「停機狀態下每個受影響端點都被打過」,不是「有沒有 fail-open 測試」。 同一輪的 token 撤銷檢查是這樣驗的;限流這條驗收時只打了公開端點,唯一掛裝飾器的端點從來沒在停機狀態下被打過。
  5. 套件宣稱的 fail-open 要自己驗。 swallow_errors 這種參數名會讓人以為已經處理好了。

相關

  • 同一輪還抓到:RequestLoggingMiddleware 在 call_next 之前讀 client_ip,而解析真實 IP 的 middleware 在更內層 → 每一筆 access log 的 client_ip 恆為 null。也是「元件測試綠、實際輸出錯」的同類。
  • 寫了但沒接上:那篇是「寫了沒接線」,這裡是它的親戚——接線了但只接了一半。比完全沒接線更難發現,因為有一部分路徑是能動的。
  • 同一個依賴、相反的故障方向:限流為什麼該 fail-open、fail-open 的測試斷言為什麼要雙向。
  • Rate Limiting:限流設計與 Redis 實作:限流本身的演算法與各框架工具(FastAPI 那列就是 slowapi)。
  • 驗收假綠形態目錄:「量錯對象」「單向斷言」這幾種假綠的通用版本。