BUZZ AI Gateway
文档 · 指南 · 错误处理与重试

错误处理与重试

先看状态码,再读 message。本文给出 BUZZ Messages API 的全部 HTTP 状态码、BUZZ 与 Anthropic 错误响应包络的差异,以及可以直接用在生产环境的 Python / Node / Go 三种指数退避模板。

HTTP 错误码总表

HTTPerror.type是否可重试?典型场景
400invalid_request_errorJSON 格式错、必填字段缺失、字段值不符合 schema。
401buzz_error / authentication_errorAPI key 缺失、格式错、已吊销。
403permission_errorKey 没有该模型或 group 的权限,IP 不在允许列表。
413request_too_largeMessages API 请求体超过 32 MB。
429rate_limit_error是(带退避)命中三维度限流(请求数、输入 token、输出 token),遵守 retry-after
500api_error / buzz_error瞬时内部错误,带退避重试。
503buzz_error · model_not_found视情况BUZZ 特有:你的 group 下当前没有可用 channel 服务该模型。
529overloaded_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_err
const 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_minute60 秒窗口内的 POST 次数retry-after客户端加并发限制,做请求队列。
input_tokens_per_minute窗口内累计 input_tokensretry-after裁剪 system prompt;用 Prompt Cache 避免重复发送同样的 token。
output_tokens_per_minute窗口内累计 output_tokensretry-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(单位:秒),再回退到自身退避策略。

Prompt Cache 是 429 的缓解手段。 缓存读命中的 token 计入 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 当成路由问题看待,不是上游故障。

排查步骤

  1. 查实时模型列表。 用同一个 API key 请求 GET /v1/models,响应是当前你的 group 能路由到的权威列表:
    curl -H "Authorization: Bearer $BUZZ_API_KEY" https://buzzai.cc/v1/models
  2. 用带日期的别名。不带日期的 claude-haiku-4-5 在某些 group 下可能不可用,但带日期的 claude-haiku-4-5-20251001 是可用的。两种 ID 都合法,生产环境优先用带日期的形式作为 canonical 值。
  3. 确认 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_error529 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 轮换,先更新密钥仓再重启服务。
403Key 合法但权限不足。检查账号的 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,其他抛给调用方。具体机制见 流式处理指南

生产清单

相关链接