OpenAI 兼容 API 401 与 429 报错排查:从鉴权到限流的完整思路

401 说明请求根本没通过鉴权——服务端在看到你要问什么之前,就已经把这把密钥毙掉了;429 正好相反,密钥本身是合法的,你是在限流或欠费这道门槛上被挡住的。这两类报错的排查路径完全不同,混着调只会白白浪费重试次数和时间。这篇文章按「先分类、再定位、后处理」的顺序讲清楚 401 和 429 的常见成因,附一段可以直接抄的 Python 指数退避代码,并说明 CaMeL Hub 在控制台建密钥、单令牌模型限制、余额不足报错这几处和其他网关不一样的地方。

先分清楚:是 401 还是 429?

HTTP 状态码本身已经把两类问题分开了:401 代表鉴权阶段就被拒绝,请求根本没有进入计费或限流环节;429 代表鉴权已经通过,是在配额或速率上被拦下的。排查时先看状态码,再看错误体里的 `type` 或 `code` 字段——只看错误文案容易踩坑,因为不同网关的措辞不统一,有的甚至会用听起来很像鉴权错误的话来描述一个限流问题。

401 的四个常见根因

按出现频率大致排了个顺序,遇到 401 先照这四条查一遍,再怀疑是服务端本身出了问题。

429 背后其实是两件不同的事:限流 vs 欠费

429 只说明「这次没让你过」,过不去的原因可能天差地别。速率限制通常按每分钟请求数或每分钟 token 数计算,目的是保护共享的服务端基础设施,等一段时间、降低并发就能自愈,同一个请求原样重发一次往往就成功了。配额耗尽是账户预付余额或套餐额度用完了,这是计费状态,不是时间问题,不管等多久、退避多少次都不会自己恢复。

多数 OpenAI 兼容实现会在错误体的 `error.type` 或 `error.code` 字段里标出区分,比如 `rate_limit_exceeded` 对 `insufficient_quota` 这类命名。先解析这个字段再决定要不要重试,对着一个已经欠费的账户空转退避循环,只会浪费时间,不会等出任何变化。

有 Retry-After 就按它来,没有再退避

429 响应如果带了 `Retry-After` 头(单位通常是秒),它就是服务端给出的最精确的等待时间——按它睡眠一次,比自己拍脑袋算退避曲线更省时间,也更不容易在限流还没解除时又发起一次请求而继续被拒。

不是所有实现都会稳定返回这个头,客户端代码要能优雅处理它缺失的情况,退回到指数退避,而不是假设它一定存在。

Python 指数退避重试,可以直接抄

下面这段装饰器命中限流类 429 会按 `Retry-After` 或指数退避加抖动重试;一旦识别出是配额/余额类错误就直接向上抛出,不做无意义的等待——余额不会因为多睡几秒而自己变多。

import random
import time
from functools import wraps

import openai


def retry_on_rate_limit(max_retries=5, base_delay=1.0, max_delay=30.0):
    # 命中 429 限流自动退避重试;命中配额/余额类错误直接抛出。
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries + 1):
                try:
                    return fn(*args, **kwargs)
                except openai.RateLimitError as e:
                    body = getattr(e, 'body', None) or {}
                    err_type = (body.get('error') or {}).get('type', '')
                    if 'quota' in err_type or 'insufficient' in err_type:
                        raise  # 余额/配额问题,重试无意义,直接抛出

                    if attempt == max_retries:
                        raise

                    retry_after = e.response.headers.get('retry-after') if e.response else None
                    if retry_after is not None:
                        delay = float(retry_after)
                    else:
                        delay = min(max_delay, base_delay * (2 ** attempt))
                        delay += random.uniform(0, delay * 0.25)  # 抖动

                    time.sleep(delay)
        return wrapper
    return decorator


@retry_on_rate_limit(max_retries=5)
def ask(client, prompt):
    return client.chat.completions.create(
        model='gpt-5.5',
        messages=[{'role': 'user', 'content': prompt}],
    )

用批量与并发上限压住突发流量

比起触发限流再去重试,更划算的做法是从请求形状上主动压低峰值。三个具体改法基本能覆盖大部分场景:能合并的请求尽量合并,比如 embedding 接口原生就支持一次传入一个字符串数组而不是逐条调用,很多「循环里挨个发」的代码稍微改改就能变成一次调用;用 `asyncio.Semaphore` 或线程池的 `max_workers` 给并发请求数设一个硬上限,不要放任 agent 框架里的并行工具调用一次性全部发出去;定时任务如果多台机器在同一秒触发,加一点随机抖动错开发起时间,避免「整点齐射」式的突发流量。

CaMeL Hub 上这两类报错的特殊之处

密钥(在 CaMeL Hub 里叫「令牌」)完全在控制台创建和管理,路径是控制台 → 令牌。这里出现 401 绝大多数是三种情况之一:令牌被删除、已经过期、或者生成时没复制完整(和多数厂商一样,它只完整显示一次)。吊销一个令牌会让所有用它签名的请求立刻全部失败,不存在生效延迟。

单令牌的模型限制会表现成鉴权类错误,而不是一条清楚的「模型不允许调用」提示。每个令牌都可以在控制台单独设置可调用模型的白名单,拿一把没开通目标模型的令牌去请求,拿到的往往是 401/403 风格的拒绝——遇到这种情况先去控制台确认这把令牌的可用模型列表,而不是先怀疑令牌本身坏了。

余额不足在 CaMeL Hub 上不会返回 429。系统把「欠费」和「限流」分得很干净,余额不够时返回的是一个独立的配额不足错误,不是限流状态码。如果重试逻辑只认 HTTP 429 就无脑重试,一个真正欠费的账户会在退避循环里跑完所有重试次数也拿不到结果——看到配额类报错应该提示充值,而不是继续等待重试。