API 返回 429:先区分限速、并发和配额,再决定是否重试

0 阅读9分钟

收到 429,先暂停无间隔重发,查看错误详情和服务文档。它能提示你检查调用限制,却不能单独说明“平台坏了”“余额不足”或“换一个 Key 就能恢复”。只有确认属于暂时性限制、操作可以安全重放时,才进入有限重试。

本文由 AI 辅助整理,技术说明和示例经过复核。资料访问与本地验证日期:2026-11-06。代码仅做等待决策与本地模拟,不连接真实模型服务。

一、先判断这次 429 在说什么

RFC 6585 将 429 定义为一定时间内请求过多,但没有统一规定识别用户和计数的方法。看到 429 后,应继续看服务自己的错误码、限制范围和响应头。

读到的信息先做什么
明确说明请求或 Token 暂时触限降低对应维度的负载,再检查等待提示
明确说明余额耗尽、支出或用量上限核对用量与账户设置;短间隔重试不能补足额度
只看到通用 429、HTML 页面或无法识别的错误停止自动重试,核查文档或向服务方提供脱敏诊断信息

例如,OpenAI 当前错误文档同时列有临时限速和 credit_balance_exhausted 等 429。Claude 当前文档也说明,消费上限响应可能仍使用 rate_limit_error。所以,“状态码是 429”或“type 里有 rate_limit”都不足以自动判定可以重试。这些字段只适用于各自官方接口,不能当作所有兼容服务的统一格式。

二、RPM、TPM、并发和配额分别限制什么

维度看什么调整方向
RPM一分钟内请求数量平滑发起请求、减少重复触发
TPM一分钟内 Token 负载;可能拆分输入/输出缩短不必要的上下文,合理设置输出上限
并发同时尚未完成的请求数限制在途任务,使用有上限的队列
配额/消费上限文档规定周期内的用量或金额核对具体额度、周期与权限

Token 的估算、预留、缓存和失败计数规则因服务而异,以当前文档为准。OpenAI与 Claude的说明都体现了多个限制维度;Stripe 文档明确区分并发和速率,这里只借用它解释概念。

一个假设例子:即使某服务允许每分钟 60 次请求,也不能据此认定“第一秒发 60 次一定允许”;实现可能还限制更短的突发窗口。再假设并发上限为 2,两个慢请求尚未完成时,第三个任务就可能需要排队。两个数字均为示意,不是任何站点的实际限额。

同样要核对限制属于 Key、用户、项目、组织、模型还是接口。多个程序可能共用同一限制范围;新建 Key 并不必然产生新额度。先检查重复点击、定时任务重叠和队列中的在途请求。

三、Retry-After 怎么读

429 响应可以带 Retry-After,并非必须带。依据 RFC 9110,值可能是非负整数秒,也可能是 HTTP 日期:

Retry-After: 10
Retry-After: Fri, 06 Nov 2026 08:00:10 GMT

第一种从收到响应后等待;第二种结合当前 UTC 时间计算剩余间隔,注意本机时钟偏差。日期例子是固定示意,不是让读者等到该时间。

有效提示要求等待 120 秒,而你的这次操作只允许再等 30 秒时,应停止本轮、交给稍后调度或人工处理。不能把 120 截成 30 后继续发送。没有合法等待提示时,才按文档和本地策略采用有限退避。

等待提示不保证下一次成功,也不能单独证明某次业务操作可安全重放。

四、重试前还要过两道判断

第一道是安全重放。生成、创建任务等 POST 操作可能产生费用或副作用;超时不能证明服务没有执行。应核对端点的幂等与去重约定。没有依据时,先查状态,不能简单把同一 POST 循环发送。RFC 9110 的幂等语义解释了这条边界。

流式响应已经交付部分内容时,不自动重播整个请求,应按端点的恢复约定处理;HTTP 200 后断流也不能直接按 429 处理。OpenAI 当前说明给出了相应边界。

第二道是次数和等待预算。退避把后续尝试拉开,抖动让多个客户端不都在同一时刻重试,但二者不会创造服务容量。AWS 的重试说明指出了额外负载和副作用问题。

还要检查 SDK、网关和业务代码是否分别重试。假设两层各允许 3 次总尝试,一次用户操作最多可能放大成 9 次底层尝试;这是乘法示意,不是某个 SDK 的默认值。统一安排重试责任,避免叠加。

五、一个不发送真实请求的 Python 示例

下面的完整文件只返回“本次是否还应等待、等待多久”。classification 是调用者核对服务文档后给出的教学分类,代码不自动识别上游错误。

教学参数为:最多 3 次总尝试,即首次请求加最多 2 次重试;单次最多等待 30 秒,累计等待预算 60 秒。这些数值用于演示,不是生产推荐阈值。累计等待不含网络与处理耗时,不能当作整次操作的硬超时。真实接入还应设置覆盖排队、请求和重试等待的端到端截止时间,并支持取消。

将完整代码保存为 retry_policy.py:

"""A deliberately conservative retry decision demo; no network or environment access.

waited is cumulative planned waiting, NOT an end-to-end wall-clock deadline.
classification must be assigned from the actual service's documented semantics.
"""
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from math import isfinite
from random import random
from typing import Callable

MAX_ATTEMPTS, MAX_WAIT, WAIT_BUDGET = 3, 30.0, 60.0


@dataclass(frozen=True)
class Plan:
    delay: float | None
    reason: str


def parse_retry_after(value: str | None, now: datetime) -> float | None:
    """Accept ASCII integer seconds or an aware HTTP-date; invalid -> None.

    A past date means zero. Naive dates are invalid. Huge digit-only values may
    become +inf: they remain valid, so the caller must stop, not retry early.
    """
    if now.tzinfo is None or now.utcoffset() != timedelta(0):
        raise ValueError("now must be timezone-aware UTC")
    if value is None:
        return None
    raw = value.strip()
    if raw and raw.isascii() and raw.isdecimal():
        return float(raw)
    try:
        date = parsedate_to_datetime(raw)
        if date.tzinfo is None or date.utcoffset() is None:
            return None
        return max(0.0, (date.astimezone(timezone.utc) - now).total_seconds())
    except (TypeError, ValueError, OverflowError):
        return None


def plan_retry(*, status: int | None, classification: str, replay_safe: bool,
               attempt: int, waited: float, retry_after: str | None,
               now: datetime, random01: Callable[[], float] = random) -> Plan:
    """attempt is 1-based; replay_safe requires an explicit replay assessment."""
    delay = parse_retry_after(retry_after, now)
    if not isinstance(attempt, int) or attempt < 1:
        raise ValueError("attempt must be a positive integer")
    if not isfinite(waited) or waited < 0:
        raise ValueError("waited must be finite and nonnegative")
    if status != 429 or classification != "temporary_rate_limit":
        return Plan(None, "not a confirmed temporary HTTP 429")
    if replay_safe is not True:
        return Plan(None, "replay safety is unconfirmed")
    if attempt >= MAX_ATTEMPTS:
        return Plan(None, "total attempt limit reached")
    if delay is None:
        jitter = random01()
        if not 0.0 <= jitter <= 1.0:
            raise ValueError("random01 must return a value in [0, 1]")
        delay = 2 ** (attempt - 1) + jitter
        source = "fallback backoff"
    else:
        source = "Retry-After"
    if delay > MAX_WAIT:
        return Plan(None, "required wait exceeds the 30s per-wait limit")
    if waited + delay > WAIT_BUDGET:
        return Plan(None, "required wait exceeds the remaining wait budget")
    return Plan(delay, source)


def demo() -> None:
    # Fake responses only. No request is sent and no sleeping occurs.
    responses = [(429, "temporary_rate_limit", "2"), (200, "ok", None)]
    waited = 0.0
    now = datetime(2026, 11, 6, 0, 0, tzinfo=timezone.utc)
    for attempt, (status, classification, header) in enumerate(responses, 1):
        print(f"attempt={attempt}, status={status}")
        if status == 200:
            print(f"success; planned_wait_total={waited:.1f}s")
            break
        plan = plan_retry(status=status, classification=classification,
                          replay_safe=True, attempt=attempt, waited=waited,
                          retry_after=header, now=now, random01=lambda: 0.5)
        if plan.delay is None:
            print(f"stop: {plan.reason}")
            break
        print(f"plan_wait={plan.delay:.1f}s ({plan.reason}); no real sleep")
        waited += plan.delay
        now += timedelta(seconds=plan.delay)


if __name__ == "__main__":
    demo()

运行:

python retry_policy.py

常规演示使用预设响应,只打印等待计划,不请求网络,也不实际等待。真实接入时,调用者必须执行获准的完整等待、更新已等待时间,再决定下一次发送;本文件不替你发送请求。

上述示例的实际输出为:

attempt=1, status=429
plan_wait=2.0s (Retry-After); no real sleep
attempt=2, status=200
success; planned_wait_total=2.0s

在 Windows / Python 3.13.11 本地验证中,19 项测试通过。补充 HTTP 样本仅由 127.0.0.1 随机端口的模拟服务器返回:429→200 发起 2 次请求;持续 429 最多发起 3 次;要求等待 31 秒时,本例因超过单次 30 秒教学上限,只发起 1 次后停止。测试用记录器代替真实等待,没有验证真实等待的墙钟精度。

这些测试只证明示例的决策与次数边界,不证明任何线上通道的稳定性或扣费行为。日期解析采用保守策略,无时区日期视为无效;本例不是完整的 HTTP 日期语法校验器。

六、下次排查时留下这张清单

  • 记录时间与时区、客户端和版本、HTTP 状态、接口类型;
  • 在本地按白名单保留具体错误码、等待提示和服务提供的限制维度;
  • 核对限制范围、并发中的任务、SDK 与业务重试总次数;
  • 明确是否可以安全重放,以及次数、等待预算和业务截止条件;
  • 原因未知、配额问题或预算不足时结束本轮,保留脱敏结论。

不要把完整请求头、Key、提示词、响应正文或账单截图发到公开评论。需要向服务方排查时,按对方要求单独提供必要且已脱敏的信息。

429 排查的结果不一定是“把重试写好”。它也可能是关闭重复任务、缩小请求、限制并发,或者先处理账户额度。先找到限制的维度,下一步才有依据。