錯誤碼
全部業務碼、對應的 HTTP 狀態、以及哪些重試有意義、哪些純屬浪費。
回應外殼
原生任務 API 的 JSON 回應使用以下結構;/v1 相容介面和 SSE 按各自協定解析:
{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }code是業務碼,不是 HTTP 碼。200表示成功。- 失敗時
data不出現(不是null),只有code、msg、request_id。 msg是給人看的,措辭會變,語言也可以選(見錯誤說明的語言)。不要拿它做程式判斷,判斷只看code。request_id也在回應標頭X-Request-Id上。回報問題時附上它。
HTTP 狀態碼同時也會正確設定。兩者並行,你可以任選一種判斷方式——但只選一種:code 與 HTTP 碼在細分碼上是不同名的(餘額不足是 code: 40201 + HTTP 402),混著判斷遲早會漏掉一類。
我們的建議是看 code:404 只能說「沒找到」,40201 能說清「可用餘額不足」。
錯誤說明的語言
給人讀的錯誤說明可以用你選的語言回傳,預設是英文。會跟著語言改變的只有這三處:
- 回應外殼裡的
msg; - 任務失敗時的
errorMessage,recordInfo與 Webhook 回呼都一樣; - 相容介面錯誤物件裡的
error.message,OpenAI、Anthropic 與 Google Gemini(/v1beta)三種格式都適用。
支援的語言代碼:en、zh-Hant、ja、ko、de、fr、es、pt-BR、ru。
怎麼選語言
依下列順序決定,排在前面的優先:
- 請求標頭
Accept-Language,只對這一次請求生效。採用標準 HTTP 權重語法,按q值由高到低,取第一個能辨識的語言。地區變體會歸到對應的語言:de-DE算de,pt-PT算pt-BR,zh-TW、zh-HK、zh-Hant都算zh-Hant。中文只提供繁體,所以zh-CN、zh-Hans不會對應到任何語言,會接著看下一個候選;*視為沒有偏好。 - 帳戶設定:控制台「設定 → API 錯誤語言」。用這個帳戶的 API 金鑰發出、又沒帶可辨識
Accept-Language的請求,都照這裡的設定。多數伺服器端 SDK 與 curl 預設不帶這個標頭,所以這是最常用的方式。Webhook 回呼沒有請求標頭可以參考,一律照帳戶設定。 - 兩者都沒有時,用英文。
錯誤回應會帶上 Content-Language 回應標頭,標明這次錯誤說明實際用的語言。
只有給人讀的文字會變
code、errorCode、OpenAI 相容層的 type / code、Gemini 相容層的 code / status,以及所有欄位名,都不隨語言改變。程式一律按碼判斷,不要解析文字;日誌與監控警示建議記錄 code 與 request_id。
範例
同一個 40201(餘額不足),預設回傳英文:
HTTP/1.1 402 Payment Required
Content-Language: en
{ "code": 40201, "msg": "Insufficient balance", "request_id": "req_…" }在請求上加一行 Accept-Language: ja,其餘與快速入門裡的建任務請求相同:
curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $SPICY_IDEMPOTENCY_KEY" \
-H "Accept-Language: ja" \
--data "$TASK_PAYLOAD"msg 換成日文,code 仍是 40201:
HTTP/1.1 402 Payment Required
Content-Language: ja
{ "code": 40201, "msg": "残高が不足しています", "request_id": "req_…" }通用碼
code | HTTP | 含義 | 該重試嗎 |
|---|---|---|---|
200 | 200 | 成功 | — |
400 | 400 | 請求格式或參數不合法 | 不該。改對了再發 |
401 | 401 | 憑證無效或已失效 | 不該。換金鑰 |
403 | 403 | 沒有存取該資源的權限 | 不該 |
404 | 404 | 資源不存在(或不屬於你) | 不該 |
409 | 409 | 請求與目前狀態衝突 | 看情況,見下文 |
40901 | 409 | 報價過期、輸入變化或價格變化 | 重新報價並確認;已受理請求繼續用原冪等鍵恢復 |
413 | 413 | 請求本文超過伺服器端允許的大小 | 不該。縮小請求本文,或依媒體上傳流程傳送檔案 |
429 | 429 | 請求過於頻繁 | 該。按 Retry-After 退避 |
500 | 500 | 我們這邊出錯了 | 該。退避後重試 |
餘額與額度
code | HTTP | 含義 | 怎麼辦 |
|---|---|---|---|
40201 | 402 | 餘額不足以覆蓋本次任務的預估費用 | 儲值。可用餘額見 GET /api/v1/chat/credit |
40202 | 402 | 達到金鑰、團隊或平台的消費上限 | 在控制台調高上限。日上限 UTC 零點重新起算,月上限每月 1 日 UTC 零點重新起算,累計上限不會自己恢復 |
這兩類重試沒有意義——餘額和上限不會因為你多請求幾次而變化。
權限
code | HTTP | 含義 | 怎麼辦 |
|---|---|---|---|
40301 | 403 | 該金鑰不允許呼叫此模型 | 在控制台放開這把金鑰的模型範圍 |
40302 | 403 | 來源 IP 位址不在該金鑰的白名單內 | 補白名單,或關掉這把金鑰的 IP 限制 |
40303 | 403 | 該地區暫不提供服務 | 無法繞過 |
全部不可重試。
模型可用性
code | HTTP | 含義 | 該重試嗎 |
|---|---|---|---|
50301 | 503 | 該模型目前不可用 | 該。稍後重試,或換一個模型 |
50301 表示建立任務時這個模型就無法受理:模型未上架、沒有生效中的價格,或暫時沒有可用的生成容量。臨時容量問題可以有限重試;模型未開放或沒有有效價格時,重複請求不會恢復可用性。重新讀取目錄,只有使用者確認後才切換模型。
受理後的生成失敗體現在 state=failed
createTask 回傳任務 ID 之後才發生的生成失敗,會出現在 recordInfo 的 state: "failed" 與 errorCode / errorMessage 裡,凍結金額全額釋放。不要自行新增公開 OpenAPI 未宣告的同步業務碼分支。
任務失敗碼
受理之後的失敗落在 recordInfo 的 errorCode 上。它是一個封閉集合:內部診斷碼不會出現在這裡,讀到列表之外的值一律按 upstream_failed 處理。
errorCode | 含義 | 該重試嗎 |
|---|---|---|
invalid_request | 參數沒有被接受 | 不該。改對參數再提交 |
unsupported_combination | 目前不支援這組參數 | 不該。換一組參數 |
content_rejected | 內容策略拒絕了這次請求 | 不該。改提示詞,或換一個模型 |
rate_limited | 生成側當時繁忙 | 該。退避後重試 |
upstream_unavailable | 生成能力暫時不可用 | 該。稍後重試 |
invalid_asset | 參考素材已不可用 | 該,但要先把素材重新上傳 |
generation_failed | 已經開始生成但沒有產出 | 看情況。同一組參數很可能會再失敗一次 |
timeout | 超過任務時限仍未完成 | 看情況。可以縮短時長或降低解析度再試 |
upstream_failed | 其餘:以上未涵蓋的失敗 | 該。退避後重試 |
以上每一種都會全額釋放凍結金額。errorMessage 是同一件事的自然語言說明,措辭會變——判斷只看 errorCode。
哪些錯誤會計費
先區分任務終態與用戶端連線狀態。
明確拒絕建立的請求、終態為 failed / expired 的任務及歷史 canceled 記錄不計生成費用。用戶端逾時或斷開連線不等於任務失敗;受理後的任務不可取消,應恢復原任務並檢視最終狀態和結算。串流中途斷線時,若已產生有效用量,仍可能計費。
| 情況 | 是否扣款 |
|---|---|
API 明確拒絕 createTask,未建立任務 | 不扣。建任務和凍結在同一筆交易內完成;閘道器錯誤或未收到回應不證明拒絕 |
| 提交後網路逾時、網頁關閉或連線中斷 | 待確認。用原冪等鍵恢復受理結果,不能當成免費重試 |
createTask 回傳 202,任務後來 failed / expired | 不扣。凍結金額全額釋放 |
舊資料中的 canceled 任務 | 不扣。這是相容狀態,不代表目前可以取消任務 |
createTask 回傳 202,任務 succeeded | 扣,按實際用量結算,但不超過受理時的凍結額;少用釋放差額,之後不補扣超過凍結額的金額 |
recordInfo、chat/credit、common/* 的任何錯誤 | 不扣。這些介面本來就不計費 |
觸發速率限制(429)、被冪等鍵擋下(重複提交) | 不扣。前者請求沒被受理,後者回傳的是首次那個任務,只扣過一次 |
失敗不計費是承諾而不是盡力而為:即使我們的程式在中途崩潰,補救掃描也會把凍結的錢退回去。可透過任務結算狀態和餘額核對處理結果,見計費。
409 什麼時候值得重試
| 情境 | 值得重試嗎 |
|---|---|
| 同鍵、同請求並行時極少出現的冪等鍵衝突 | 值得。退避幾百毫秒,帶同一個鍵和同一份請求重試。見冪等鍵 |
| 同一個鍵已經用於不同請求或另一把 API Key | 不值得。停止並修正鍵的歸屬;重試不會改變衝突 |
| 輸出檔案還在轉存 | 值得。等幾秒再取得下載網址 |
重試該怎麼寫
不要無腦重試
400、401、40201 這一類,換多少次結果都一樣。上表裡標「不該」的,重試是純粹的浪費——而且每一次都在啃你自己的速率限制額度。
指數退避 + 隨機抖動(避免一批失敗的請求同時回來打成驚群):
// 409 與具體操作有關,必須由呼叫點結合操作語義處理。
const RETRYABLE = new Set([429, 500, 50301]);
async function callWithRetry(path, init, { attempts = 4 } = {}) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(`https://api.spicyapi.ai/api/v1${path}`, init);
const body = await res.json();
if (body.code === 200) return body.data;
if (!RETRYABLE.has(body.code)) {
throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
}
// 觸發速率限制時,伺服器端已經告訴你該等多久,別自己猜
const retryAfter = Number(res.headers.get('retry-after'));
const backoff = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 2 ** i * 1000 + Math.random() * 500;
await new Promise((r) => setTimeout(r, backoff));
}
throw new Error('重試次數已用盡');
}import random, time, requests
# 409 與具體操作有關,必須由呼叫點結合操作語義處理。
RETRYABLE = {429, 500, 50301}
BASE = "https://api.spicyapi.ai/api/v1"
def call_with_retry(method: str, path: str, attempts: int = 4, **kwargs) -> dict:
for i in range(attempts):
res = requests.request(method, f"{BASE}{path}", **kwargs)
body = res.json()
if body["code"] == 200:
return body["data"]
if body["code"] not in RETRYABLE:
raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
# 觸發速率限制時,伺服器端已經告訴你該等多久,別自己猜
retry_after = res.headers.get("retry-after")
backoff = float(retry_after) if retry_after else 2**i + random.random() * 0.5
time.sleep(backoff)
raise RuntimeError("重試次數已用盡")package main
import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"strconv"
"time"
)
// retryable 是值得重試的業務碼。不在這張表裡的碼,
// 換多少次結果都一樣——重試只是在啃自己的速率限制額度。
// 生成本身是否失敗,要從 recordInfo 的 state 讀。
var retryable = map[int]bool{429: true, 500: true, 50301: true}
type envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
RequestID string `json:"request_id"`
}
func callWithRetry(newReq func() (*http.Request, error), attempts int, out any) error {
for i := 0; i < attempts; i++ {
req, err := newReq()
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
// 連線層失敗:請求可能已經到達。建任務時務必帶同一個
// Idempotency-Key,否則這裡的重試會變成重複生成。
time.Sleep(backoff(i, ""))
continue
}
var env envelope
decErr := json.NewDecoder(res.Body).Decode(&env)
io.Copy(io.Discard, res.Body)
res.Body.Close()
if decErr != nil {
return decErr
}
if env.Code == 200 {
return json.Unmarshal(env.Data, out)
}
if !retryable[env.Code] {
return fmt.Errorf("%d %s (request_id=%s)", env.Code, env.Msg, env.RequestID)
}
// 觸發速率限制時,伺服器端已經告訴你該等多久,別自己猜
time.Sleep(backoff(i, res.Header.Get("Retry-After")))
}
return fmt.Errorf("重試次數已用盡")
}
// backoff 優先採信 Retry-After,否則指數退避加隨機抖動
//(抖動是為了避免一批失敗的請求同時回來打成驚群)。
func backoff(attempt int, retryAfter string) time.Duration {
if s, err := strconv.Atoi(retryAfter); err == nil && s > 0 {
return time.Duration(s) * time.Second
}
base := time.Duration(1<<attempt) * time.Second
return base + time.Duration(rand.Intn(500))*time.Millisecond
}重試 createTask 時一定要帶上同一個 Idempotency-Key,否則「上一次其實成功了,只是回應遺失了」會變成重複生成、重複扣費。
疑難排解
拿到一個看不懂的錯誤時,依這個順序看:
request_id—— 回報問題時給我們這個,我們能直接定位到那一次請求。code—— 上面的表。- 控制台的請求日誌 —— 每一次呼叫的請求參數、回應內容、耗時與計費都在那裡。
500 的 msg 恆為一句通用的「服務暫時不可用」,不含任何內部細節——這是刻意的,內部結構、元件名稱、堆疊都不會出現在回應裡。這類問題只能靠 request_id 查。

