API Engineering

在不觸發反濫用啟發式規則的情況下緩解 API 速率限制

4 min read Published Updated 808 words

大多數 API 速率限制的實作都能被單純的代理輪換輕易繞過——但同樣的輪換手法卻會在幾分鐘內觸發反濫用啟發式規則。真正的挑戰不僅是保持在限制之下,更是要在過程中不顯得像是機器人。這需要理解兩種不同的速率限制範圍(每個 IP 與每個金鑰),應用帶抖動的退避策略、隨機化代理池輪換,並調整請求時序以模仿有機流量。將令牌桶模式疊加在代理池之上,能提供穩固的基礎。

IP 層級與金鑰層級:速率限制的兩個維度

速率限制至少作用於兩個獨立的維度:來源 IP 與 API 金鑰(或令牌)。單一金鑰可能允許每小時 5,000 次請求(GitHub 的已驗證限制),但同一金鑰來自單一 IP 時,可能因較低的突發上限而被節流。未驗證的請求則更受限——通常每個 IP 每小時 60 次請求。忽略 IP 維度是觸發 429 Too Many Requests(RFC 6585)回應最快的方式。代理池必須將請求分散到多個 IP,以避免任何單一來源飽和,但每個 IP 仍共用同一金鑰。如果金鑰的全域限制是每小時 5,000 次,而你有 50 個代理,那麼每個代理每小時只能發出 100 次請求,否則金鑰層級的計數器就會觸發。在撰寫任何程式碼之前,先繪製出這兩個限制的邊界。

帶抖動的退避:禮貌與可預測之間的差異

沒有抖動的指數退避是一種指紋。伺服器會看到請求以完美倍增的間隔到達,並將其標記為自動化。解決方案是全抖動:sleep(random.uniform(0, min(cap, base * 2 ** attempt)))。這能將重試分散到時間視窗內,使模式與真實使用者的突發行為難以區分。AWS 自身的指數退避文件也基於此原因推薦使用抖動。以下 Python 片段實作了一個令牌桶,以固定速率補充令牌,在桶空時以抖動方式休眠,並在每次成功獲取令牌時輪換代理:

import time
import random
from collections import deque

class TokenBucketProxyPool:
    def __init__(self, proxies, rate, burst):
        self.proxies = deque(proxies)
        self.rate = rate          # tokens per second
        self.burst = burst
        self.tokens = burst
        self.last_refill = time.monotonic()

    def refill(self):
        now = time.monotonic()
        elapsed = now - self.last_refill
        self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
        self.last_refill = now

    def acquire(self):
        while True:
            self.refill()
            if self.tokens >= 1:
                self.tokens -= 1
                self.proxies.rotate(1)   # simple rotation, but see section below
                return self.proxies[0]
            else:
                sleep_time = (1 - self.tokens) / self.rate
                jitter = random.uniform(0, sleep_time * 0.5)
                time.sleep(sleep_time + jitter)

# Usage: pool = TokenBucketProxyPool(proxies, rate=2.0, burst=10)
#         proxy = pool.acquire()   # blocks until token available

代理池輪換:隨機化,不要輪詢

依序輪詢代理清單是可偵測的。伺服器若看到請求依序來自 1.2.3.45.6.7.89.10.11.12,便會將此模式關聯到不同 IP。相反地,應以可重複的方式隨機選取代理,並為每個代理設定冷卻時間。公共代理目錄回報 60–80% 的失敗率——回傳 429 或連線錯誤的代理應被降級至死亡佇列至少 60 秒。實作加權隨機選取,讓近期成功的代理有更高機率被選中。上述令牌桶為了清晰使用了簡單的 rotate(1);在生產環境中應將其替換為 random.choice(),並追蹤每個代理的失敗次數。

請求塑形:模仿平均值,但不越線

即使擁有完美的令牌桶和代理池,恆定每秒 2.0 次的請求速率仍不自然。真實使用者會暫停、批次處理,並改變請求間隔。在每次 acquire() 呼叫前加入一個小的隨機延遲(例如 time.sleep(random.uniform(0.1, 0.5))),並每隔幾分鐘將令牌桶的速率變化 ±10%。然而,不要加入超過 API 文件所載速率限制視窗的延遲——那會適得其反。目標是在限制內運作,同時看起來像一群合法客戶端。過度塑形(例如插入人類打字般的延遲)是白費力氣;API 關心的是請求頻率,而非按鍵間隔時間。

道德緩解:知道界線在哪裡

這裡描述的每一項技術都是合法的最佳化——直到它違反 API 的服務條款。大多數 ToS 明確禁止「規避速率限制」或「使用自動化方式存取服務」。閱讀 ToS 並非選擇性事項。如果 API 允許每個金鑰每分鐘 100 次請求,使用 50 個代理各發出每分鐘 2 次請求是合規的。使用 100 個代理各發出每分鐘 100 次請求則不然——無論你的抖動多麼巧妙,這都是濫用。區別在於意圖與流量。疊加在代理池上的令牌桶是一種工具;同一工具可用於道德地爬取公共目錄,也可用於對小型 API 發動 DDoS 攻擊。記錄你的速率限制目標、審查日誌中的 429 回應,且絕不超過文件所載的金鑰層級上限。如果需要更高的吞吐量,請申請更高的限制或付費使用專用方案。