問題
在一個 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 ← 同一個呼叫、不同斷言同一個請求、兩個斷言結果相反,才逼出「只在成功路徑觸發」這件事。
解法
_ensure_view_rate_limit(request):缺該屬性就補None。slowapi 的_inject_headers在current_limit is None時本來就什麼都不做,所以補None的語意是「這次沒有可回報的限流狀態」,不是把例外掩蓋掉。- 自家的
rate_limit()裝飾器包住limiter.limit(),進套件 wrapper 前先補值。保證放在裝飾器身上,新端點只要用它就自帶 fail-open,不必知道 middleware 存不存在、掛在哪一層。 - middleware 的
dispatch最前面也補一次,早於所有提早返回的分支(豁免路由、限流關閉、被_should_exempt跳過的路由)。兩層都做是刻意的 defence in depth。 - AST 守門測試禁止任何模組直接用
@limiter.limit或@limiter.shared_limit(後者走同一個__limit_decorator,同樣會中),唯一豁免是包裝它們的那個模組。
修完之後,同樣停掉 Redis 的序列重打一次,登入回 200。
為什麼這樣解
把保證放在裝飾器而不是文件或 code review 上,是因為這個洞的特性是「新增一個端點就可能重蹈覆轍」。守門測試讓違規在 CI 就紅,而不是等 Redis 掛掉才發現。
守門測試的形式也講究:assert offenders == [] 配 AST 掃描,注入一個違規檔就會紅——這是「重構後仍會紅」的形式,而且實際注入違規檔反向驗證過它會紅。反例是 assert.NotNil(常數) 這種斷言:常數永遠不是 nil,改了實作也不會紅,等於沒有守門。
怎麼預防再次發生
這條是通用的,不限 slowapi、不限 Python:
- 降級策略(fail-open/fail-closed)的驗證,必須在依賴實際停機的狀態下打真實端點,元件層測試不算數。fail-open 的基本要求之一是「不得崩潰」,而元件層測試證不到「整條請求鏈不崩潰」。登入這類未認證路徑前面沒有 fail-closed 的關卡,停真 Redis 實打是有效的量法;認證後的元件要改用選擇性故障注入,見判準那篇的「量測陷阱」。
- 窮舉受影響的路徑,不要抽樣。 同一個機制常常有多條接線方式(middleware/裝飾器/手動呼叫),套件只保證它自己那條。修的時候要問「這個機制總共有幾個入口」。
- 降級測試要涵蓋成功路徑,不能只測失敗路徑。 錯誤處理的程式碼常常在「提早返回」時跳過後續邏輯,於是 bug 只在 happy path 觸發——直覺上剛好相反。
- 驗收看的是「停機狀態下每個受影響端點都被打過」,不是「有沒有 fail-open 測試」。 同一輪的 token 撤銷檢查是這樣驗的;限流這條驗收時只打了公開端點,唯一掛裝飾器的端點從來沒在停機狀態下被打過。
- 套件宣稱的 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)。
- 驗收假綠形態目錄:「量錯對象」「單向斷言」這幾種假綠的通用版本。