錯誤處理與重試

402 / 403 / 429 語義

01錯誤格式

所有錯誤回應皆為 JSON,單一 detail 欄位。參數驗證錯誤時,detail 會直接指出問題與可用的合法值:

422
{
  "detail": "emotion 無效值「思念」。「思念」屬 theme,請改用 theme=思念。合法值:中性、喜悅、厭惡、平靜、悲傷、憤怒、恐懼、激昂、驚訝、愛慕"
}

情緒與主題是兩個不同的維度,是最常見的混淆來源。 全部合法值可由 /api/lexicon/vocabulary 一次取得。

02狀態碼一覽

意義應對
200成功
401密鑰無效或已撤銷不要重試。檢查密鑰是否貼漏字元、是否已被撤銷。
402該組織尚未開通或訂閱已失效不要重試。到主控台完成訂閱,或改用免費組織的密鑰。
403密鑰的 scope 不涵蓋此端點不要重試。放寬 scope 或改用另一條密鑰。
404查無此詞條 / 此分類不要重試。此為正常的「找不到」,非故障。
409在未開通的組織中簽發密鑰不要重試。先開通該組織,密鑰始終跟隨組織的方案。
422參數不合法不要重試。依 detail 修正參數。
429超出速率或配額依 Retry-After 退避後重試。見下節。
5xx伺服器端錯誤指數退避重試,最多 3 次。
404
{"detail": "詞條「鑫鑫鑫」不存在於本詞庫"}

03沒有密鑰不會報錯

這一點最容易踩中
未帶 Authorization 標頭的請求不會回 401 —— 它會以匿名身分成功回應,配額只有每日 1,000 個詞條、每頁上限 50、且無法深層分頁。

也就是說,密鑰設定錯誤(環境變數沒讀到、標頭拼錯)在測試時會「看起來正常」, 直到上線後流量一大就撞匿名配額。建議在啟動時做一次自我檢查:

健康檢查
curl -s -H "Authorization: Bearer $WRITERHK_API_KEY" \
  "https://api.writer.hk/api/lexicon/usage" | grep -q '"identity":"api_key"' \
  || echo "密鑰未生效 —— 目前以匿名身分呼叫"

/api/lexicon/usageidentity 欄位會明確回報是api_key 還是匿名。密鑰無效(而非缺漏)則會實實在在回 401:

401
{"detail": "Invalid or revoked API key."}

04429:兩種完全不同的情況

兩者都是 429,但退避時間差 60 倍,必須分開處理 —— 用 Retry-Afterdetail 區分:

每分鐘速率超限
429  Retry-After: 60  X-RateLimit-Limit: 60
{"detail": "Rate limit exceeded. Slow down."}
每日詞條配額用盡
429  Retry-After: 3600  X-Quota-Limit: 1500
{"detail": "Daily dictionary quota reached. Upgrade your plan for higher limits."}
  • 速率超限:降低併發後重試即可,通常一分鐘內恢復。
  • 配額用盡:當日不會恢復(配額於 00:00 UTC 重設),重試只是浪費 —— 應該改為降級服務或提示使用者,並考慮提升方案。

要完全避免撞到配額上限,請在每個成功回應讀取 X-Quota-Remaining, 在接近零時自行節流。

05重試策略

只有 429(速率)與 5xx 值得重試。4xx 的其餘各碼重試不會改變結果, 只會消耗速率額度。

TypeScript
async function call(url: string, key: string, tries = 3): Promise<Response> {
  for (let i = 0; i < tries; i++) {
    const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
    if (res.ok) return res;

    // 配額用盡:當日唔會好返,重試冇意義
    const retryAfter = Number(res.headers.get('Retry-After') || 0);
    if (res.status === 429 && retryAfter > 300) throw new Error('daily quota exhausted');

    if (res.status !== 429 && res.status < 500) return res;   // 其餘 4xx 唔重試
    await new Promise((r) => setTimeout(r, (retryAfter || 2 ** i) * 1000));
  }
  throw new Error('exhausted retries');
}

官方 SDK 未內建重試 —— 退避策略與你的服務等級有關,交由呼叫方決定比較合理。

06降級模式

當計量層(Redis)暫時不可用時,服務不會整體中斷,而是進入降級模式:

  • 付費層照常服務 —— 付費客戶有合約與計量紀錄,不因基礎設施故障而斷線。
  • 匿名與 Free 層會被結構性收窄(每分鐘上限降至 10、禁止分頁), 因為此時無法計量,不能讓不受約束的身分趁機大量提取。

/api/lexicon/usagedegraded 欄位會回報目前是否處於此模式。 降級期間的用量數字可能不準確,但不影響資料本身的正確性。