请求被拒时,先不要急着更换密钥。要判断请求卡在密钥权限还是调用配额上,需要用三处信息交叉确认:HTTP 状态码、错误信息里的状态名和相关字段、控制台用量页在失败时间点附近的记录。仅凭一句“请求失败”就更换密钥,很容易把配额问题当成权限问题,结果更换后仍然失败;也可能把权限问题当成配额问题,白等一个配额窗口。比较稳妥的做法是:先存下一整条失败上下文,再按照判据分类,最后用带退避的重试兜住偶发抖动。
先把一条失败请求的完整上下文存下来,再谈判断。状态码 401/403 多数指向密钥本身,或该密钥对目标接口的授权范围;429 且用量页在同一时间段出现抬升,才更像配额或速率窗口用尽。两类错误的动作不同:权限类要去核对密钥绑定的是哪个项目、该项目是否开通了目标接口;配额类要看用量页的时间粒度,并考虑带退避的重试。没有用量记录佐证时,不要直接下结论。
记录一次失败的完整上下文
这一步的目的,是让错误可以被复现和比对。如果同一条请求前后两次失败,但记录的字段不一致,后面的判断基本就断了。建议在调用封装层统一输出一行结构化日志,需要保存的字段如下:
| 字段 | 取值说明 | 用途 |
|---|---|---|
| 请求时间 | 带时区的 ISO 时间,例如 2025-01-01T10:00:03+08:00 | 与控制台用量页的时间轴对齐 |
| 状态码 | HTTP 状态码或等价的 gRPC 状态码 | 第一层分类依据 |
| 错误信息文本 | 响应体里的 message、status、details 原样截取 | 区分同状态码下的不同原因 |
| 密钥标识(脱敏) | 只留前 4 位和后 4 位,或密钥的别名/创建时间 | 判断是否多把密钥共用同一项目 |
| 调用的接口名 | 完整路径或资源方法名 | 确认是否只有某个接口被拒 |
日志里不要把整串密钥打出来,用别名或脱敏串代替即可。同一个项目下有多把密钥时,这一列是判断“是不是某把密钥单独被限制”的关键。
把权限错误与配额错误分开
两类错误的处理动作完全不同,混在一起排查只会来回试。可以先按下表的判据做第一层分类,再用控制台用量做第二层确认:
- 401 类:通常是密钥没有正确携带、密钥本身无效或已失效。下一步看密钥是否存在、是否被复制时截断、请求头字段名是否写错。
- 403 类:密钥有效,但对目标接口或目标资源没有授权。下一步看密钥所属项目是否开通了该接口、密钥是否被限定在特定接口白名单、项目是否处于受限状态。
- 429 类:请求被限流或配额耗尽。下一步看用量页在该时刻的记录,以及配额窗口的粒度和重置时间点。
- 400 / 404 类:多为参数或接口名写错,既不是权限也不是配额,先不要往重试方向走。
错误信息文本是重要的补充判据。常见的状态名里,类似 PERMISSION_DENIED 这类通常落在授权问题,类似 RESOURCE_EXHAUSTED 这类通常落在配额或限流问题。需要注意边界:429 并不必然等于配额用完,也可能是短时间内的速率限制,所以必须回到用量页确认,不能只凭状态码下结论。
在控制台核对用量与配额窗口
各家控制台的路径不完全一致,通常位于“项目设置 → API 与用量”或“配额 / Quotas”页面。进入后先确认三件事:用量是按项目统计还是按密钥统计、时间粒度是分钟级还是小时级、配额窗口的重置点是固定周期还是滚动窗口。这三项决定了你能不能把日志里的时间戳和用量曲线对上。
对齐方法是:把失败请求的时间戳转成控制台使用的同一个时区,找到对应的时间桶,再看该桶的用量是否已经贴近上限。如果用量页显示的那一刻用量平缓,而请求却返回 429,更可能是速率类限制而不是总额度耗尽;如果用量在失败时刻恰好到达上限,才更倾向于配额用完,此时更换密钥通常不会增加额度,因为额度挂在项目上。
给调用加上带退避的重试
偶发的配额抖动或短时限流,用一次退避重试往往就能过去。下面的骨架是通用写法,send 需要替换成你自己的请求函数,status_code 和 body 的取法也要按实际 SDK 调整:
import time, random, logging
RETRYABLE = {429, 500, 502, 503, 504} # 可重试:限流、配额抖动、服务端临时错误
FATAL = {400, 401, 403, 404} # 不可重试:参数、鉴权、授权、路径错误
MAX_ATTEMPTS = 4
BASE_DELAY = 2.0 # 秒,按实际配额窗口长度调整
def call_with_backoff(send, payload, key_alias):
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
return send(payload)
except Exception as e:
status = getattr(e, 'status_code', None)
body = (getattr(e, 'body', '') or str(e))[:300]
logging.warning(
'attempt=%s key=%s status=%s body=%s',
attempt, key_alias, status, body
)
if status in FATAL:
raise # 权限类错误重试无意义,直接抛出
if status not in RETRYABLE or attempt == MAX_ATTEMPTS:
raise # 次数用尽或未知错误,放弃
delay = BASE_DELAY * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
time.sleep(min(delay, 30)) # 间隔递增并封顶,避免长时间挂住
放弃条件要写死:状态码落在 FATAL 集合、重试次数达到上限、或者累计等待时间超过业务可接受范围,三者任一满足就抛出。日志必须每次重试都打一行,否则事后无法判断这次成功是重试救回来的,还是本来就没问题。
把结论写回配置注释
排查完的结论如果只留在聊天记录里,下次换人还会重来一遍。建议把配额窗口、可重试与不可重试的状态码直接写进配置文件的注释,让下一个人打开就能看到:
gemini_carbon:
endpoint: 'https://<your-endpoint>/v1/<resource>' # 实际地址以控制台给出的为准
key_env: 'GEMINI_CARBON_KEY' # 密钥从环境变量读,不进仓库
# 配额:额度挂在项目上,不挂单把密钥;换密钥不增加额度
quota_scope: 'project'
quota_window: '60s' # 控制台用量页的最小粒度,按此对齐失败时间戳
# 可重试:429 疑似限流/配额抖动,需配合用量页确认;5xx 为服务端临时错误
retryable_status: [429, 500, 502, 503, 504]
# 不可重试:401 密钥无效或未携带,403 密钥有效但无该接口授权
fatal_status: [400, 401, 403, 404]
max_attempts: 4
backoff: '2s, 4s, 8s,带随机抖动,单次上限 30s'
注释里写清楚“为什么这么配”,比只写取值更有用。等配额窗口或接口授权发生变化时,改配置的人能直接判断哪些行需要跟着改。