跳轉至

建立 2026-10-02 更新 2026-10-02

限制請求速率

限制請求速率(rate limiting,也常叫「限流」)是規定「同一個來源在一段時間內最多能打幾次 API」。例如登入端點限制「每個 IP 每分鐘最多 5 次」,可以擋住暴力猜密碼、爬蟲狂刷,也避免單一使用者把伺服器資源吃光。

FastAPI 本身沒有內建限流功能。本頁示範兩種做法:用社群套件 SlowAPI(實務首選),以及自己用依賴(Depends)寫一個,幫助理解原理。

先搞懂三個問題

任何限流方案都在回答這三件事:

問題 說明 常見選擇
用什麼當 key 「同一個來源」怎麼認 IP、使用者 ID、API key
規則是什麼 多久內幾次 5/minute、100/hour
計數存在哪 次數記在哪裡 記憶體(單機)、Redis(多台/多 worker)

超過上限時,標準做法是回 HTTP 429 Too Many Requests,並在 Retry-After header 告訴客戶端幾秒後可以再試。

計數演算法

  • 固定視窗(fixed window):每分鐘從 0 開始算。簡單省資源,但在視窗交界處可能短時間內放行兩倍請求(59 秒打 5 次、61 秒再打 5 次)。
  • 滑動視窗(moving / sliding window):永遠看「過去 60 秒」打了幾次,比較精準,代價是要記住每次請求的時間。
  • 權杖桶(token bucket):桶子以固定速度補充權杖,每次請求花掉一個,允許短暫爆量。Nginx、雲端 API Gateway 常用。

入門只要知道:要嚴格就用滑動視窗;要省資源就用固定視窗。

用 SlowAPI:每分鐘最多 5 次

SlowAPI 是把 Flask-Limiter 移植到 Starlette/FastAPI 的套件,底層用 limits 處理計數,是 FastAPI 社群最常見的選擇。

uv add slowapi
# 或 pip install slowapi
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)  # (1)!

app = FastAPI()
app.state.limiter = limiter  # (2)!
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)  # (3)!


@app.get("/hello")
@limiter.limit("5/minute")  # (4)!
async def hello(request: Request):  # (5)!
    return {"message": "hi"}
  1. key_func 決定「同一個來源」怎麼認。get_remote_address 取客戶端 IP。
  2. SlowAPI 從 app.state.limiter 找到這個 limiter,名稱不能改。
  3. 超過上限時 SlowAPI 拋出 RateLimitExceeded,這個 handler 把它轉成 429 回應。
  4. 規則字串格式是「次數/時間單位」,例如 5/minute、100/hour、1000/day、10/second。也可寫 5 per minute。
  5. 函式一定要有 request: Request 參數,SlowAPI 要從它取 IP。少了它,程式啟動時就會報錯:No "request" or "websocket" argument on function。

裝飾器順序也要注意:@app.get(...) 在上,@limiter.limit(...) 在下(緊貼函式)。

同一個 IP 連打 6 次,第 6 次會得到:

HTTP/1.1 429 Too Many Requests
{"error": "Rate limit exceeded: 5 per 1 minute"}

同時套多條規則

規則可以疊加,全部都要滿足才放行:

@app.post("/login")
@limiter.limit("5/minute")
@limiter.limit("20/hour")
async def login(request: Request):
    ...

或寫成一個字串:@limiter.limit("5/minute;20/hour")。

開啟 headers_enabled=True,回應會帶上 X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset 與 Retry-After,前端可以據此顯示「還剩幾次」。

from fastapi import Response

limiter = Limiter(key_func=get_remote_address, headers_enabled=True)


@app.get("/hello")
@limiter.limit("5/minute")
async def hello(request: Request, response: Response):  # 需要 response 參數
    return {"message": "hi"}

開啟 header 時要加 response: Response

端點回傳 dict 時,SlowAPI 要透過 response 參數把 header 寫上去。少了它會在執行時報錯。

全站預設上限

不想每個端點都加裝飾器時,設定 default_limits 並掛上中介軟體,所有路由都會套用;個別端點再用 @limiter.limit 覆寫,或用 @limiter.exempt 排除(例如健康檢查)。

from slowapi.middleware import SlowAPIMiddleware

limiter = Limiter(key_func=get_remote_address, default_limits=["100/minute"])
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)


@app.get("/items")  # 套用預設 100/minute
def list_items():
    return []


@app.get("/health")
@limiter.exempt  # 不限流
def health():
    return {"ok": True}

依使用者或 API key 限流

用 IP 當 key 的問題是:公司、學校、行動網路常常很多人共用一個 IP。已登入的 API 通常改用使用者身分或 API key:

def user_or_ip(request: Request) -> str:
    api_key = request.headers.get("X-API-Key")
    if api_key:
        return f"key:{api_key}"
    return f"ip:{get_remote_address(request)}"


limiter = Limiter(key_func=user_or_ip)

key_func 回傳的字串就是計數用的 key。加上 key:、ip: 前綴,避免兩種來源撞名。

改用滑動視窗與 Redis

SlowAPI 預設用固定視窗、計數存在行程記憶體裡。這在正式環境有兩個問題:

  • 用 fastapi run --workers 4 或多台機器時,每個 worker 各算各的,實際上限會變成 5 × 4 = 20 次。
  • 伺服器重啟,計數歸零。

改成共用 Redis 就能解決,並可順便換成滑動視窗:

limiter = Limiter(
    key_func=get_remote_address,
    storage_uri="redis://localhost:6379",  # 需另外 uv add redis
    strategy="moving-window",              # 預設是 "fixed-window"
)

storage_uri 也支援 memcached://、mongodb:// 等,見 limits 儲存後端說明。

自己寫:用依賴實作每分鐘 5 次

不想加套件、或只是想理解原理,可以用一個可呼叫的類別當依賴(見 依賴注入)。下面用滑動視窗:每個 IP 一條佇列,記錄過去 60 秒內每次請求的時間。

import time
from collections import defaultdict, deque

from fastapi import Depends, FastAPI, HTTPException, Request, status


class RateLimiter:
    def __init__(self, times: int, seconds: int):
        self.times = times
        self.seconds = seconds
        self.hits: dict[str, deque[float]] = defaultdict(deque)

    def __call__(self, request: Request) -> None:
        key = request.client.host if request.client else "unknown"
        now = time.monotonic()
        window = self.hits[key]

        # 丟掉已經超過視窗時間的紀錄
        while window and now - window[0] >= self.seconds:
            window.popleft()

        if len(window) >= self.times:
            retry_after = int(self.seconds - (now - window[0])) + 1
            raise HTTPException(
                status_code=status.HTTP_429_TOO_MANY_REQUESTS,
                detail="請求太頻繁,請稍後再試",
                headers={"Retry-After": str(retry_after)},
            )

        window.append(now)


app = FastAPI()
five_per_minute = RateLimiter(times=5, seconds=60)


@app.get("/hello", dependencies=[Depends(five_per_minute)])
def hello():
    return {"message": "hi"}

重點:

  • five_per_minute 是同一個實例,所以計數會跨請求保留。若寫成 Depends(RateLimiter(5, 60)) 放在每個端點,各端點會有各自獨立的計數,這通常也是你要的。
  • 要整組路由都限流,可掛在 router 上:APIRouter(dependencies=[Depends(five_per_minute)])。
  • 用 time.monotonic() 而不是 time.time(),系統校時不會讓計數亂掉。

這個版本只適合單一行程

計數存在記憶體,多 worker、多台機器或重啟都會失效;hits 也不會清掉沒再出現的 IP,長期執行會慢慢吃記憶體。學原理、內部工具可以用;正式環境請改用 SlowAPI + Redis,或交給閘道層處理。

部署時的注意事項

反向代理後面拿到的 IP 不對

放在 Nginx、Traefik 或雲端負載平衡器後面時,request.client.host 會變成代理的 IP,等於所有人共用一個額度,5 次很快就用完。

解法是讓 Uvicorn 信任代理傳來的 X-Forwarded-For(見 佈署):

fastapi run main.py --proxy-headers --forwarded-allow-ips="10.0.0.5"

--forwarded-allow-ips 只填你自己的代理 IP。設成 * 而 API 又直接對外時,攻擊者可以偽造 X-Forwarded-For,每次換一個假 IP 就繞過限流。

也可以在閘道層限流

應用程式層限流的好處是能依「使用者」「方案等級」細分規則。但大量惡意流量最好在更前面擋掉,不要讓它進到 Python:

  • Nginx:limit_req 模組,例如 limit_req_zone $binary_remote_addr zone=api:10m rate=5r/m;。
  • 雲端服務:Cloudflare、AWS API Gateway/WAF 等都有限流規則。

常見組合是:閘道層設寬鬆的全站上限擋濫用,FastAPI 層設細緻的業務規則(例如登入 5 次/分、免費方案 1000 次/天)。

測試限流

用 測試 介紹的 TestClient 連打 6 次,確認第 6 次是 429:

from fastapi.testclient import TestClient

from main import app

client = TestClient(app)


def test_rate_limit():
    for _ in range(5):
        assert client.get("/hello").status_code == 200
    response = client.get("/hello")
    assert response.status_code == 429

計數會跨測試保留。若多個測試打同一端點,可在 fixture 裡呼叫 limiter.reset()(SlowAPI)清空,或在測試中把 app.state.limiter.enabled = False 關掉限流。

相關資料