错误处理与重试
先看状态码,再读 message。本文给出 BUZZ Messages API 的全部 HTTP 状态码、BUZZ 与 Anthropic 错误响应包络的差异,以及可以直接用在生产环境的 Python / Node / Go 三种指数退避模板。
HTTP 错误码总表
| HTTP | error.type | 是否可重试? | 典型场景 |
|---|---|---|---|
| 400 | invalid_request_error | 否 | JSON 格式错、必填字段缺失、字段值不符合 schema。 |
| 401 | buzz_error / authentication_error | 否 | API key 缺失、格式错、已吊销。 |
| 403 | permission_error | 否 | Key 没有该模型或 group 的权限,IP 不在允许列表。 |
| 413 | request_too_large | 否 | Messages API 请求体超过 32 MB。 |
| 429 | rate_limit_error | 是(带退避) | 命中三维度限流(请求数、输入 token、输出 token),遵守 retry-after。 |
| 500 | api_error / buzz_error | 是 | 瞬时内部错误,带退避重试。 |
| 503 | buzz_error · model_not_found | 视情况 | BUZZ 特有:你的 group 下当前没有可用 channel 服务该模型。 |
| 529 | overloaded_error | 是(更长退避) | Anthropic 上游全行业过载,换路或等待。 |
两种错误响应包络
BUZZ 返回两种可区分的错误形状,取决于错误发生在哪一层。客户端两种都要兼容。
Anthropic 直传包络
上游模型自身拒绝请求时使用,与 Anthropic 直连一致:
{
"type": "error",
"error": {
"type": "rate_limit_error",
"message": "Number of request tokens has exceeded your per-minute rate limit"
},
"request_id": "req_011CR..."
}
BUZZ 网关侧包络
BUZZ 自己产生的错误使用此格式 —— 鉴权拒绝、schema 校验、channel 路由失败等。注意:没有顶层 type:"error" 包裹,没有独立 request_id,BUZZ 的 request id 拼在 error.message 末尾:
{
"error": {
"type": "buzz_error",
"message": "Invalid token (request id: 202605260713594...)"
}
}
生产代码应该按 error.type 分支,并兼容两种包络:
def parse_error(resp_json):
err = resp_json.get("error", {})
return {
"type": err.get("type", "unknown"),
"message": err.get("message", ""),
# Anthropic 形式 request_id 在顶层
# BUZZ 形式 request id 在 message 末尾
"request_id": resp_json.get("request_id"),
}
指数退避模板
瞬时错误(429、500、529 以及网络层失败)的正确套路是 full jitter 指数退避。务必设上限,避免上游一分钟的抖动把你的尾延迟拖死。
import os
import random
import time
import httpx
BASE_DELAY = 1.0 # 秒
MAX_DELAY = 32.0 # 上限
MAX_ATTEMPTS = 5
RETRYABLE_STATUS = {429, 500, 502, 503, 504, 529}
def call_messages(payload):
headers = {
"Authorization": f"Bearer {os.environ['BUZZ_API_KEY']}",
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
}
last_err = None
for attempt in range(MAX_ATTEMPTS):
try:
r = httpx.post(
"https://buzzai.cc/v1/messages",
json=payload,
headers=headers,
timeout=120,
)
except httpx.RequestError as e:
last_err = e
else:
if r.status_code < 400:
return r.json()
if r.status_code not in RETRYABLE_STATUS:
# 非 429 的 4xx 不重试
r.raise_for_status()
last_err = httpx.HTTPStatusError(
f"HTTP {r.status_code}: {r.text[:200]}",
request=r.request, response=r,
)
# 优先遵守 retry-after
retry_after = r.headers.get("retry-after")
if retry_after:
time.sleep(float(retry_after))
continue
# full jitter:[0, base * 2^attempt) 内随机,带上限
delay = min(MAX_DELAY, BASE_DELAY * (2 ** attempt))
time.sleep(random.uniform(0, delay))
raise last_errconst RETRYABLE = new Set([429, 500, 502, 503, 504, 529]);
const BASE_DELAY_MS = 1000;
const MAX_DELAY_MS = 32_000;
const MAX_ATTEMPTS = 5;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function callMessages(payload) {
let lastErr;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
try {
const resp = await fetch("https://buzzai.cc/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.BUZZ_API_KEY}`,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (resp.ok) return await resp.json();
if (!RETRYABLE.has(resp.status)) {
const body = await resp.text();
throw new Error(`HTTP ${resp.status}: ${body.slice(0, 200)}`);
}
lastErr = new Error(`HTTP ${resp.status}`);
const retryAfter = resp.headers.get("retry-after");
if (retryAfter) {
await sleep(Number(retryAfter) * 1000);
continue;
}
} catch (e) {
lastErr = e;
}
const cap = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
await sleep(Math.random() * cap);
}
throw lastErr;
}package buzz
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"time"
)
var retryable = map[int]bool{
429: true, 500: true, 502: true, 503: true, 504: true, 529: true,
}
const (
baseDelay = time.Second
maxDelay = 32 * time.Second
maxAttempts = 5
)
func CallMessages(payload any) (map[string]any, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
req, _ := http.NewRequest(
"POST",
"https://buzzai.cc/v1/messages",
bytes.NewReader(body),
)
req.Header.Set("Authorization", "Bearer "+os.Getenv("BUZZ_API_KEY"))
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err == nil {
buf, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < 400 {
var out map[string]any
_ = json.Unmarshal(buf, &out)
return out, nil
}
if !retryable[resp.StatusCode] {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(buf))
}
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err2 := strconv.Atoi(ra); err2 == nil {
time.Sleep(time.Duration(secs) * time.Second)
continue
}
}
} else {
lastErr = err
}
cap := baseDelay << attempt
if cap > maxDelay {
cap = maxDelay
}
time.Sleep(time.Duration(rand.Int63n(int64(cap))))
}
return nil, lastErr
}429:三维度限流
Anthropic 同时执行三套独立预算,任何一个触顶都会触发 429。要先弄清是哪一个,才能决定是降并发、缩 prompt 还是缩输出。
| 维度 | 统计什么 | 响应头 | 缓解办法 |
|---|---|---|---|
| requests_per_minute | 60 秒窗口内的 POST 次数 | retry-after | 客户端加并发限制,做请求队列。 |
| input_tokens_per_minute | 窗口内累计 input_tokens | retry-after | 裁剪 system prompt;用 Prompt Cache 避免重复发送同样的 token。 |
| output_tokens_per_minute | 窗口内累计 output_tokens | retry-after | 降低 max_tokens,要求模型回答更精简。 |
错误 message 通常会指明触发的是哪一个维度:
{"type":"error","error":{"type":"rate_limit_error",
"message":"Number of input tokens has exceeded your per-minute rate limit"}}
重试循环必须先尊重响应头里的 retry-after(单位:秒),再回退到自身退避策略。
cache_read_input_tokens,不计入 input_tokens。BUZZ 实测:同一 1202-token 的 system,第一次 cache_creation=1200, input=2,第二次 cache_read=1200, input=2,实际计入 input_tokens_per_minute 的只有 2 token。详见 Prompt Caching 概念。
503 buzz_error · model_not_found 排查
这个状态码是 BUZZ 特有的。含义:你请求的 model 在你账号所属的 group 下当前没有可路由 channel。Anthropic 直连不会返回 503 —— 直连的"未知模型"是 404。把 503 model_not_found 当成路由问题看待,不是上游故障。
排查步骤
- 查实时模型列表。 用同一个 API key 请求
GET /v1/models,响应是当前你的 group 能路由到的权威列表:curl -H "Authorization: Bearer $BUZZ_API_KEY" https://buzzai.cc/v1/models - 用带日期的别名。不带日期的
claude-haiku-4-5在某些 group 下可能不可用,但带日期的claude-haiku-4-5-20251001是可用的。两种 ID 都合法,生产环境优先用带日期的形式作为 canonical 值。 - 确认 group 权限。 全局模型目录有但你的
GET /v1/models拿不到,说明你的 group 没有授权。联系支持开通对应 channel。
典型错误信息
HTTP/1.1 503 Service Unavailable
content-type: application/json
{"error":{"type":"buzz_error",
"message":"No available channel for model claude-haiku-4-5-20251001 under group aws (request id: 202605260713...)"}}
message 末尾的 request id 是支持快速诊断的关键 —— 提工单时原样贴上。
529:全行业过载与多上游回退
529 和 429 性质完全不同。429 说的是你账号预算超了。529 说的是所有人共用的上游容量满了,你个人预算其实没问题。提升账号 tier 对 529 没有任何效果 —— 限制不在你这边。
| 429 rate_limit_error | 529 overloaded_error | |
|---|---|---|
| 作用域 | 账号级 | 全行业 |
| 客户端可做的 | 降速或升级 tier | 换路 |
| 退避 | 短抖动,遵守 retry-after | 长抖动(5 到 30 秒) |
| 持续时间 | 窗口滚动到位即恢复 | 等上游容量恢复 |
回退策略
对付 529 的干净办法是有第二个发请求的地方。两种模式生产环境都好用:
模式 A:BUZZ 内部模型档位回退
如果任务可以接受小一档的模型,Sonnet 回退到 Haiku、Opus 回退到 Sonnet。同一个网关、同一个 SDK 调用,只换 model id。BUZZ 不会把请求绑死在某个模型上,所以这种回退几乎零成本。
def call_with_fallback(messages, max_tokens=400):
fallbacks = [
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5-20251001",
]
last = None
for model in fallbacks:
try:
return call_messages({
"model": model,
"max_tokens": max_tokens,
"messages": messages,
})
except Exception as e:
# 只在瞬时错误(429、500、503、529)时回退
if not is_transient(e):
raise
last = e
raise last
模式 B:客户端多网关回退
对自身有韧性要求的应用,把 BUZZ 作为主路、另一个 base URL 作为备路。两边的 payload 字节级完全一致,只换 base URL 和 key。
BASE_URLS = [
("https://buzzai.cc", os.environ["BUZZ_API_KEY"]),
("https://api.anthropic.com", os.environ["ANTHROPIC_API_KEY"]),
]
for base_url, key in BASE_URLS:
try:
return call(base_url, key, payload)
except RetryableError:
continue
raise LastError
这正是 BUZZ 透明转发让你能做到的:你发给 BUZZ 的字节就是 Anthropic 收到的字节,响应字节也是 Anthropic 返回的字节。换 endpoint 是机械动作。
不可重试错误的处理
非 429 的 4xx 说的是你代码错了,不是上游问题。重试它们只会浪费配额、放大 bug。
| HTTP | 动作 |
|---|---|
| 400 invalid_request_error | 把 message 直接抛给开发者。常见原因:messages 角色交替断裂、max_tokens 超模型上限、tool_use round-trip 格式错误。 |
| 401 | 从控制台重发 key。如果你做 key 轮换,先更新密钥仓再重启服务。 |
| 403 | Key 合法但权限不足。检查账号的 group 配置或 IP 允许列表。 |
| 413 | 裁剪请求。32 MB 上限是编码后 JSON 体的字节数,不是 prompt token 数。 |
流式中的错误
已经收到 HTTP 200 后,后续任何失败都以 SSE event: error 帧形式出现,而不是非 2xx。帧形如:
event: error
data: {"type":"error","error":{"type":"overloaded_error","message":"..."}}
处理方式和对应的非流式状态码完全一致:overloaded_error 长退避重试,rate_limit_error 遵守 retry-after,其他抛给调用方。具体机制见 流式处理指南。
生产清单
- 按
error.type分支,不要只看 HTTP 状态码。同一个 400 可能是不同类别。 - 两种错误包络(Anthropic 和 BUZZ)都要兼容。
- 先尊重
retry-after,再回退到 full-jitter 指数退避。 - 重试上限 5 次。需要更多次的不是瞬时问题,是结构问题。
- 400、401、403、413 永远不重试 —— 只会消耗自己的配额。
- 每次失败都记录
request_id(或 BUZZ message 末尾的 request id)。支持诊断完全靠它。 - 面对 529 要规划好回退路径,而不是只把 sleep 拉长。