cd ../back to blog
$Guide//June 4, 2026//7 min read

限流、重試與退避:AI API 的正式環境錯誤處理

用六條規則打造可靠的 AI API 錯誤處理:只重試可重試的、採全抖動指數退避、聽從 Retry-After、設好逾時、冪等鍵與熔斷器。

Demo 在第一次呼叫就成功了。正式環境是另外那 999,999 次呼叫 — 那些在流量尖峰時撞上限流、在部署途中接到上游 503、或卡在一個永遠不回應的 socket 上的呼叫。玩具級整合 與可靠整合之間的差別,幾乎全在你怎麼處理那條不順的路徑:你 重試什麼、等多久、又在什麼時候放棄。處理錯了,供應商短短打個嗝,就會 變成你自己造成的服務中斷 — 因為你每一個 worker 都整齊劃一地重試, 親手把限流器起的頭給收了尾。

這是一份務實、達到正式環境等級的逐步指南:把錯誤分類好,讓你只重試 真正可重試的,採指數退避 並加上抖動、聽從 Retry-After、設好合理的 逾時、用冪等性讓重試變得安全,再用熔斷器停止對一個已死的依賴狂敲。 程式碼用的是對接 https://api.brievio.com/v1 的 OpenAI-SDK Python,但這些 規則在任何 HTTP 客戶端、任何語言上都一樣。

只重試可重試的 — 其餘一概不重試

AI API 錯誤處理裡最常見的單一 bug,就是去重試那些永遠不會成功的東西。401(金鑰錯)、400(請求格式錯)、 422(你的提示太長)— 這些都是確定性的。第二次嘗試會和 第一次一模一樣地失敗,差別只在於現在是五次嘗試、又過了好幾秒之後。 更糟的是,你把一個真實的 bug 藏在了重試迴圈後面。唯一值得重試的 4xx429(被限流),因為它確實是 暫時性的。

  • 重試: 429,以及 500 / 502 / 503 / 504 — 這些是暫時性的伺服器端或 容量端狀況。
  • 重試: 連線錯誤與讀取逾時(請求可能根本沒抵達 模型,或者模型把答案回進了一個已關閉的 socket)。
  • 絕不重試: 其他任何 4xx 400401403 404422。把它們明確拋出、發出告警、 修好呼叫端。

一個有用的直覺:重試是在賭同一個請求會得到不同的答案。只有當 失敗的原因是時機或容量、而非請求本身時,這個賭注才會成立。

採抖動的指數退避

一旦你確定某個失敗可重試,接下來的問題就是該等多久。立刻重試毫無 意義 — 造成那個 429 的狀況,在一毫秒後依然存在。標準答案 是指數退避:大約先等 0.5s,再 1s2s 4s,每次嘗試翻倍,並設一個天花板,免得漫長的復原把你 無止盡地卡住。

但純指數退避在規模化時有一個惡性的失敗模式。如果 500 個 worker 在 同一瞬間全被限流 — 而這正是尖峰時會發生的事 — 它們會退避同樣的 時間,然後在同一瞬間一起重試,以 2 秒為週期把尖峰重新製造 一遍。這就是驚群。解法是抖動:把每個延遲隨機化,讓 重試散開。全抖動(在零到退避天花板之間隨機睡一段時間)打散驚群的 效果,遠勝於只在固定延遲上加一點點隨機微調。

retry.py
# 一個你真的能上線的重試包裝器。兩條規則就做完了大部分的事:
#   1. 只重試可重試的錯誤(429 + 5xx + 連線錯誤)。永遠別重試 400/401/422。
#   2. 採指數退避「並」加上抖動,否則每個客戶端都會同步重試。
import random
import time

from openai import OpenAI, APIStatusError, APIConnectionError, APITimeoutError

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
    timeout=30,          # 每次請求的硬上限 — 見下方「逾時」一節
)

RETRYABLE_STATUS = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 5
BASE_DELAY = 0.5         # 秒
MAX_DELAY = 20.0         # 為退避設上限,避免緩慢復原把流程無止盡卡住

def chat_with_retry(**kwargs):
    for attempt in range(MAX_ATTEMPTS):
        try:
            return client.chat.completions.create(**kwargs)
        except APIStatusError as e:
            # 不是 429 的 4xx 是「你」的 bug(參數錯、金鑰錯、太長)。
            # 重試它只是白白消耗延遲 — 就讓它快速失敗。
            if e.status_code not in RETRYABLE_STATUS:
                raise
            last_error = e
        except (APIConnectionError, APITimeoutError) as e:
            # 網路抖一下,或我們的逾時觸發了。重試一次讀取是安全的。
            last_error = e

        if attempt == MAX_ATTEMPTS - 1:
            break

        # 採全抖動(full jitter)的指數退避: sleep ∈ [0, base * 2**attempt]。
        # 真正能把驚群(thundering herd)打散的,是全抖動,而不是
        #「退避 + 一點點隨機」。可參考 AWS 架構部落格談抖動退避的文章。
        ceiling = min(MAX_DELAY, BASE_DELAY * (2 ** attempt))
        time.sleep(random.uniform(0, ceiling))

    raise last_error

注意這裡的嘗試次數上限MAX_ATTEMPTS = 5)與延遲上限MAX_DELAY)。無上限的重試 正是讓一個暫時性的小嗝演變成永遠排不空的積壓的元兇:請求堆積的速度 快過消化的速度、延遲攀升,連上游呼叫端也跟著逾時、重試它們自己的請求。兩個上限都設好,讓失敗顯露出來,而不是被緩存掩蓋。

聽從 Retry-After — 別用猜的

你的退避曲線,是對容量何時釋出的一種猜測。當伺服器直接把答案告訴你 時,就用它。429 通常會帶上一個 Retry-After 標頭(秒數差或一個 HTTP 日期),反映的是真實的重置視窗。睡那個值, 絕對勝過任何公式,因為它是真相,而非啟發式的推測。

retry_after.py
# 當伺服器主動告訴你該等多久,就聽它的。429(有時還有
# 503)會帶上 Retry-After 標頭。聽它的,勝過你自己發明的任何退避
# 曲線,因為它反映的是真實的重置視窗 — 而不是猜測。
import email.utils as eut
import time

def retry_delay(resp_headers, attempt, base=0.5, cap=20.0):
    # 1. 優先採用伺服器的指示。
    ra = resp_headers.get("retry-after")
    if ra is not None:
        try:
            return float(ra)                       # 秒數差形式: "2"
        except ValueError:
            when = eut.parsedate_to_datetime(ra)   # HTTP 日期形式
            return max(0.0, when.timestamp() - time.time())

    # 2. 沒有這個標頭? 退回到採全抖動的指數退避。
    import random
    return random.uniform(0, min(cap, base * (2 ** attempt)))

# 備註:
#   - 有些供應商也會送 X-RateLimit-Reset; 用同樣的方式處理。
#   - 加一個極小的下限(例如 50ms),免得「Retry-After: 0」變成熱迴圈空轉。
#   - Brievio 在 429 上會明確帶出 Retry-After,而不是悄悄把連線
#     卡住,所以這條路徑是走得到的 — 你真的能依訊號退避。

而這只有在閘道真的把標頭回傳出來、而不是把限流吞掉、再把你的 socket 卡上九十秒時,才行得通。 Brievio 快速且明確地失敗 — 限流會以一個乾淨的 429Retry-After 回來,而不是一條掛住的 連線 — 所以「聽伺服器的」這條路徑是走得到的。完整的錯誤分類,以及 哪些代碼帶哪些標頭,都寫在 錯誤參考裡。

逾時:沒人測試的那個失敗模式

如果請求根本不回來、無從重試,那再好的重試策略也沒用。一個沒有逾時 的 AI 呼叫,最終一定會掛住 — 一條半開的連線、一個卡死的上游、一個 丟掉了流的負載平衡器。沒有截止期限,那一個請求就會占住一個 worker、 一條連線,以及它後面每一條佇列裡的一個位子,直到幾分鐘後作業系統 放棄為止。設一個明確的每請求逾時(上面的 timeout=30), 讓卡住的呼叫轉成一個你能重試的 APITimeoutError,而不是 一坨死重量。

  • 每請求逾時限定單一次嘗試的上限。對串流而言,有 意義的預算是「首個權杖時間」加上「分塊間的閒置逾時」,而不是一個 牆鐘總時間。
  • 整體截止期限限定整段重試序列的上限。記一個預算 (例如端到端 60s),一旦花完就停止重試 — 在等你的呼叫端,自己也 有它的截止期限。
  • 逾時設在預期的 p99、而非 p50 之上。把它設在剛好 高過你真實尾延遲的位置。設得太緊,你就會取消那些本來要成功的好 請求,憑著沒耐性硬是製造出負載。

冪等性:讓重試變安全

重試會帶來一個微妙的風險。當一個請求逾時時,你並不知道伺服 器到底有沒有處理它 — 你對回應的讀取失敗了,但那份工作可能已經完成。 盲目重試,你就可能重複向客戶收費、把通知送兩次,或寫進一筆重複的 資料列。讀取天生就能安全重試。有副作用的就不行。

防線是冪等鍵(idempotency key):你替一個邏輯操作 附上的唯一 ID,讓伺服器(或你自己的處理器)把重複的請求收攏掉。對 純推論呼叫來說,通常沒有外部副作用要擔心 — 但一旦某次回覆會觸發一筆 資料庫寫入、一筆付款,或一則對外訊息,就要替每一個邏輯工作單位產生 一個穩定的鍵,並據以去重。一條經驗法則: 如果一次重試可能發生兩次,就當它一定會發生那樣去設計。

熔斷器:別再對一個已死的依賴猛踹

退避處理的是單一個掙扎中的請求。對於一場持續性的中斷,它 什麼也做不了 — 如果某個上游硬當機兩分鐘,每個請求都會把整道重試 階梯跑完、等滿最大時間,然後照樣失敗,同時你的延遲與佇列深度 爆炸。熔斷器會把這件事短路掉:在連續 N 次失敗後 它會「斷開(open)」,立即讓新呼叫失敗(或把它們導向後備方案)一段 冷卻時間,然後放一個探測請求過去測試是否復原,再決定要不要重新 閉合。

  • 閉合(Closed):正常運作,請求照常流動,失敗會被 計數。
  • 斷開(Open):門檻觸發 — 在冷卻期間快速拒絕,而 不是堆積一堆注定失敗的重試。
  • 半開(Half-open):冷卻過後,放一個試探請求過去; 成功就閉合熔斷器,失敗就重新斷開。

把熔斷器搭配一條後備路徑,中斷就會變成降級,而不是一場硬失敗。這也 正是閘道展現價值的地方:Brievio 會做跨供應商容錯切換, 所以單一供應商陷入黑暗時,還能在你的熔斷器需要斷開之前,就把流量 導向一個健康的供應商。關於這如何融入一份真實的可靠性預算,可參考 我們如何打造 99.95% 的 SLO

談談重試的成本

積極調校重試,會讓人對帳單緊張 — 每次重試不就是又一筆要計費的呼叫 嗎?在一個誠實計費的閘道上,不是這樣。Brievio 對失敗的 4xx/5xx 呼叫一毛不收,所以那個觸發你退避的 429、 以及你重試越過的那個 503,都是免費的。你只為真正回傳了 一次完成結果的那次嘗試付費,而不是為系統拒絕掉的那些。這代表你能為 了可靠性去調校嘗試次數與逾時,而不必為此付出計量表上的代價 — 而萬一失控的重試仍然讓你擔心預算,也可以直接設定支出上限,做法見 如何為你的 AI API 支出設上限

重點整理

AI API 的正式環境錯誤處理就是六條規則,而你可以在一個下午之內把它們 全部上線:

  • 只重試 4295xx。絕不重試其他 4xx — 它們是你的 bug 被顯露出來了。
  • 全抖動的指數退避,並為嘗試次數與延遲都設上限。
  • 當伺服器送來 Retry-After 時就聽它的 — 它勝過任何 公式。
  • 設明確的每請求逾時與一個整體截止期限;絕不讓一個呼叫掛住。
  • 用冪等鍵讓有副作用的重試變安全。
  • 加上熔斷器,讓持續性的中斷以降級收場,而不是整個熔毀。

這些都不奇特 — 它就是那種枯燥的基礎設施,守住「持續可用」這個同樣 枯燥的承諾。它在一個會快速失敗、把真實訊號顯露出來、又不為失敗向你 收費的底層上,運作得最好,讓你的調校既誠實又免費。如果你還在猶豫 要把流量指向哪裡,失敗情境下的可靠性表現,正是值得最先測試的事項 之一 — 這部分涵蓋在 如何挑選 AI API 閘道