← 返回资讯

API 5xx 怎么处理:500/502/503 的重试与熔断

2026-08-07

线上跑得好好的一个批处理任务,凌晨两点开始成片报错,日志里全是 502 Bad Gateway。值班的同事第一反应是翻自己的代码:是不是 prompt 拼错了?是不是 key 过期了?是不是消息体格式变了?折腾了半小时,什么都没查出来,天亮了任务自己就好了。

这半小时本来可以不用花。因为 502 已经把答案写在脸上了——问题不在你这边

4xx 和 5xx 之间那条线,是整套错误处理的地基

HTTP 状态码的第一位数字不是装饰。4xx 和 5xx 的区别,决定了你的代码接下来该干两件完全相反的事:

  • 4xx 是”你的请求有问题”。请求体语法非法、凭据缺失或无效、权限不够、请求体过大、格式合法但语义错误——这些错误的共同点是:你把同一个请求原封不动重发一万次,得到的还是同一个错。重试在这里不但没用,还会把你的配额、连接池和日志一起浪费掉。4xx 的正确反应是停下来改请求,不是重试。
  • 5xx 是”上游有问题”。你的请求本身没毛病,是对面的服务在这一刻没能处理好它。这类失败天然是瞬时的:过一会儿再发同一个请求,很可能就成功了。5xx 的正确反应就是重试——而且重试是唯一正确的反应。

我见过不少人把这两类混在一个 except Exception 里统一处理:要么全都重试三次(于是 401 也被重试三次,白等了六秒还是同一个错),要么全都直接抛出去(于是一次网络抖动就让整个批处理任务挂掉)。这两种写法都在同一个地方犯错:没有按状态码分流

在写任何重试逻辑之前,先把这条线画出来:

类别含义该不该重试该做什么
2xx成功正常处理
4xx(除 429 外)请求本身有问题不该记录、告警、修请求
429请求没问题,但你发太快了该,但要退避降速 + 指数退避,见 429 限流处理
5xx上游有问题该,且可以更积极短退避重试,连续失败则换通道

429 是个特例:它长在 4xx 里,但语义上更接近”稍后再来”。它值得单独一篇来讲,这里只需要记住它和 5xx 的处理方式并不相同——后面会有专门的对照表。

逐个看常见的 5xx

500 Internal Server Error:对面自己崩了

这是最含糊的一个码。它的字面意思是”服务端内部出错了”,但不告诉你出在哪儿。可能是某个后端节点抛了未捕获异常,可能是某个下游依赖超时了,也可能是这个请求触发了某个边界情况。

通常意味着什么:单个请求踩到了一个不确定的坑。它往往不是全局故障——同一时刻其他请求可能好好的。

该等多久:短。500 常常是单点抖动,等 1 秒重试一次就有相当概率成功。不需要像 429 那样一上来就退避到几十秒。

要不要换通道:单次 500 不用。但如果同一个请求连续多次都是 500,就要怀疑不是抖动,而是这个请求本身触发了对面的某个 bug——比如一个特别长的 prompt、一个特殊字符、一个罕见的参数组合。这时候继续重试就是死循环,应该把这条请求隔离出来单独查,而不是让它在重试队列里无限打转。

这里有个实用的判断法:同一个请求重试三次都是 500,就不再是”上游抖动”了,把它扔进死信队列,换下一个。抖动不会那么执着。

502 Bad Gateway:中间那层没拿到有效响应

502 的语义比 500 精确得多:请求经过的某个网关或代理,从它的上游拿到了一个无效的响应

现在的推理服务前面几乎都摞着好几层——负载均衡、API 网关、调度层,然后才是真正跑模型的节点。502 说明这条链路中间断了:可能是后端节点在滚动重启,可能是某个实例被健康检查踢掉了但连接还没排干,也可能是响应在传输途中被截断。

通常意味着什么:链路层面的短暂不一致,而不是你的请求有问题。502 常常成片出现,因为一个节点出问题会影响打到它上面的所有请求。

该等多久:稍长一点,几秒级。502 背后往往是”某个实例正在下线/重启”,这个过程需要一点时间才能收敛。1 秒重试大概率还是打到同一个坏节点上。

要不要换通道这是最该换通道的一个码。502 成片出现意味着上游的某一层整体不健康,你的重试解决不了对面的滚动重启。如果你有第二家平台可用,502 连续出现就是切换信号。

503 Service Unavailable:对面知道自己不行

503 是唯一一个”上游主动承认”的码:服务当前不可用,可能在维护,也可能过载。

这个码有个特点——它经常伴随 Retry-After 响应头。如果对面给了这个头,照它说的等,别自作聪明。你的退避算法再精妙,也没有服务端自己知道它什么时候能恢复。

通常意味着什么:容量或维护问题。过载型的 503 和 429 有点像,但成因不同:429 是”你超了你的配额”,503 是”整个池子都满了”。后者你降速也未必有用,因为不是你一个人在压。

该等多久:有 Retry-After 就听它的;没有的话按秒级退避,并且比 500 更耐心一点。维护窗口通常是分钟级的,不是毫秒级。

要不要换通道:要。持续 503 意味着这个上游在这段时间里就是不可用的,等下去只是把你的超时预算烧光。

关于 504 和其他码

除了这三个,你可能还会遇到其他 5xx,比如网关超时那一类。它们的处理原则和上面一致(属于上游侧失败、可重试),但具体哪些码会出现、各自代表什么,以你所接平台的官方错误码文档为准——不同平台列的清单并不一样,有的会自定义额外的码(后面专门讲)。超时本身还有一层单独的坑,展开在 超时设置 里。

一条能改变策略的事实:5xx 可能不计费

Groq 在其官方错误码文档中明确写了:5xx 响应不计费(来源:Groq 官方文档 console.groq.com/docs/errors)。其他平台的口径以各自官方文档为准——这一条我只在 Groq 的文档里核实过,不要假设所有平台都一样,也不要假设”服务端错了当然不该收钱”就是行业惯例。真要依赖这个假设做预算,去对应平台的文档或账单明细里确认一遍,这件事只需要五分钟。

但这条事实一旦成立,它对策略的影响是实打实的:重试 5xx 的边际成本可能是零

这和 429 完全不是一回事。429 你退避是因为再压下去还是被拒,而且可能触发更严厉的限制;5xx 你重试,在不计费的前提下,失败的那次连账单都不产生。所以:

  • 5xx 的重试次数可以比 4xx 类错误更宽松——3 到 5 次是合理的;
  • 5xx 的退避起点可以更短——秒级起步,而不是几十秒;
  • 5xx 的重试不需要为了省钱而克制

不过”不计费”不等于”免费”,这里有三笔账还是要付:

  1. 时间。每次重试都占用你的端到端延迟预算。用户在等一个回复,你在后台重试了四次,用户感受到的就是”这个功能好慢”。
  2. 连接与并发。重试请求同样占用连接池和你自己的并发额度。一个上游大面积 5xx 的时候,如果所有请求都在盲目重试,你的客户端会先被自己的重试打爆——这就是所谓的”重试风暴”。
  3. 机会成本。你花在重试一个坏通道上的每一秒,都是本来可以用来打另一个健康通道的时间。

第 2 点尤其容易被低估。上游整体故障时,所有请求都会失败,所有请求都会触发重试,瞬间的请求量会变成平时的 N 倍,全打在一个已经趴下的服务上。这既救不了对面,也拖垮了自己。这是熔断存在的全部理由。

5xx 和 429,处理方式几乎处处相反

把两者并排放,差异非常清楚:

维度429 Too Many Requests5xx 服务端错误
成因你发太快/太多,超了配额上游自身故障或过载
责任方你(可通过降速消除)对面(你改不了)
该不该退避必须退避,且要有抖动要退避,但可以更短
退避起点较长,秒到十秒级较短,秒级起步
重试次数保守,且要配合全局降速可以更宽松(3-5 次)
要不要换通道不一定——换了通道你的总速率还是那么高——连续 5xx 说明这个上游整体不可用
计费通常算作被拒请求,视平台口径Groq 官方明确不计费;其他平台以各自官方为准
是否该告警持续 429 该告警(说明容量规划不够)单次不该;错误率 + 持续时长双条件才告警
根治手段限流器 + 配额规划多通道回退 + 熔断

最容易搞混的是”要不要换通道”那一行。429 换通道往往解决不了问题:你的请求速率是你自己的行为,换一家平台,同样的速率大概率还是会撞上限。真正的解法是在客户端加限流器,把发送速率主动压到配额以内。而 5xx 换通道几乎总是有效:对面故障和你的行为无关,换一个健康的上游,问题当场消失。

退避算法本身(指数增长、随机抖动、上限封顶)两者是共用的,那套逻辑在 重试与退避策略 里已经讲透,这里不重复。这里要强调的是:同一套退避算法,参数应该按错误类别分开配。一个 max_retries=3, base_delay=1 打天下的写法,对 429 太激进,对 5xx 又太保守。

什么时候该停止重试、换通道

重试和切换解决的是两类不同的问题:

重试解决抖动,切换解决故障。

抖动是”这一次不行,下一次行”,重试正好对症。故障是”这一段时间都不行”,重试再多次也只是把同一堵墙撞 N 遍。区分它们的信号就是连续性

  • 偶发的、单个请求的 5xx → 抖动,重试就够了;
  • 连续 N 个请求(或某个时间窗内错误率超过阈值)都是 5xx → 故障,该换通道了。

这就是熔断器(circuit breaker)的核心思想。它的状态机不复杂:

  • 关闭(Closed):正常放行。同时统计失败率。
  • 打开(Open):失败率或连续失败数超过阈值,直接拒绝所有请求,不再打上游。这一步是保护双方的:既不让自己的重试风暴雪上加霜,也不让每个请求都白等一个超时。
  • 半开(Half-Open):熔断一段时间后,放一个探测请求过去。成功就回到关闭,失败就继续打开。

熔断和多通道回退是天生一对:熔断负责判断”这个上游现在不能用了”,回退负责回答”那用哪个”。

# 骨架示意:按错误类别分流 + 熔断计数
# 阈值与等待时间按你自己的业务观测调整,不要照抄

class UpstreamDown(Exception):
    pass

def classify(status_code: int) -> str:
    if status_code < 400:
        return "ok"
    if 500 <= status_code < 600:
        return "retry_upstream"   # 上游故障:重试 + 计入熔断
    if status_code == 429:
        return "retry_backoff"    # 限流:退避 + 全局降速,不计入熔断
    return "fatal"                # 其余 4xx:不重试,直接上报

def call_with_policy(client, breaker, payload):
    if breaker.is_open():
        raise UpstreamDown("circuit open")

    for attempt in range(MAX_RETRY_5XX):
        resp = client.post(payload)
        kind = classify(resp.status_code)

        if kind == "ok":
            breaker.record_success()
            return resp
        if kind == "fatal":
            breaker.record_success()   # 不是上游的锅,别污染熔断统计
            raise FatalRequestError(resp)
        if kind == "retry_backoff":
            sleep(backoff_for_429(attempt))
            continue

        # retry_upstream
        breaker.record_failure()
        if breaker.is_open():
            raise UpstreamDown("circuit tripped mid-retry")
        sleep(backoff_for_5xx(attempt))

    raise UpstreamDown("retries exhausted")

两个细节值得单独点出来:

第一,4xx 不该计入熔断统计。 熔断器统计的是”上游健不健康”。如果你把 401、400 也算进失败率,那么一次批量的参数写错,就会把一个完全健康的通道熔断掉。上面代码里 fatal 分支调 record_success() 看着别扭,但逻辑是对的:这次调用证明了上游是活着的,只是你的请求不对。

第二,429 也不该计入熔断。 理由同上:对面能返回 429,说明它活得好好的,只是嫌你太吵。把 429 计入熔断,结果是你一压力大就把自己的通道熔断了,这显然不是你想要的。

至于切过去之后怎么保证语义正确——尤其是”第一次请求其实成功了,只是响应没回来”的那种情况——那属于幂等设计的范畴,见 幂等与去重。多通道的编排、健康度打分和回切策略,见 多通道故障转移

非标准错误码:别让它掉进”未知错误”里

标准 HTTP 状态码就那么些,但平台可以自定义。Groq 的官方错误码文档里就列出了两个自定义码(来源:Groq 官方文档):

  • 498:Flex Tier 容量超限
  • 499:调用方取消请求

这两个码不在任何标准 HTTP 规范里。问题来了:你的重试逻辑如果是这么写的——

if 500 <= status < 600:
    retry()
elif status == 429:
    backoff()
else:
    raise

那么 498 和 499 会一起掉进最后那个 else,被当成”不可重试的客户端错误”直接抛出去。但这两个码的正确处理方式完全不同:

  • 498 是容量类问题,本质上和限流同族——该退避重试,或者换一个通道,而不是把任务判死;
  • 499 是你自己取消了请求——它压根不该进重试队列,也不该触发告警,因为那是你的代码主动干的(超时取消、用户关闭连接、上层 context 被取消)。把它当故障统计,会让你的错误率曲线凭空多出一堆噪声。

一个码该重试却被判死,另一个码不该告警却在告警。这就是”未知错误”兜底的代价。

其他平台有没有自定义码、是哪些码、什么含义,必须去各自的官方错误码文档确认——这个不能靠猜,也不能拿一家的清单套到另一家头上。

分流要按状态码 + 错误体的 type,不要按文案

Groq 的错误响应结构是:JSON 里有一个 error 对象,包含 message(描述文本)和 type(分类,例如 invalid_request_error)(来源:Groq 官方文档)。多数 OpenAI 兼容的平台也是类似的结构,但具体字段和取值以各自官方文档为准

关键在于:type 是给机器看的,message 是给人看的

我见过太多这样的代码:

# 反面教材:千万别这么写
if "rate limit" in err.message.lower():
    backoff()
elif "overloaded" in err.message.lower():
    switch_channel()

这种正则/关键词匹配一定会在某天半夜炸掉,原因很朴素:文案是会变的。平台改一版提示语、加个前缀、把英文换成本地化文案、把 “rate limit” 写成 “rate_limit”,你的分支就全部失效——而且是静默失效,所有错误一起掉进 else,你的重试逻辑就这么悄无声息地停止工作了。等你发现的时候,通常是因为成功率莫名其妙掉了一截。

正确的分流优先级是:

  1. HTTP 状态码——最稳定的信号,先按它分大类;
  2. 错误体里的 type 字段——平台定义的机器可读分类,用来做细分(比如同为 400,是参数问题还是内容问题);
  3. message 文本——只用来打日志给人看,永远不要用来做控制流判断。

再加一条兜底原则:遇到没见过的状态码,按”上游侧问题”保守处理——记录下来、告警一次、当作可重试但计入熔断。这比默认抛出去安全得多。因为平台随时可能加新码,而你的代码上一次更新是三个月前。

顺带一提,流式响应里的错误更麻烦一点:连接已经建立、状态码已经是 200 了,错误是在数据流中途出现的,上面这套按状态码分流的逻辑在那里需要额外的处理层。

告警怎么设:单次 5xx 不该响

这是我见过的最普遍的运维反模式:给 5xx 配一条”出现即告警”的规则。

上线第一周,群里被刷了三百条告警。第二周,所有人把这个告警组静音了。第三周,真的出了一次两小时的大面积故障,没有一个人看到。

告警疲劳不是纪律问题,是设计问题。 单次 5xx 是这个世界的常态——分布式系统里,任何一个节点在任何一秒重启,都会产生几个 5xx。它们已经被你的重试逻辑自动消化掉了,用户根本没有感知。为一件已经被自动处理好的事情叫醒一个人,纯属浪费。

值得告警的从来不是”发生了 5xx”,而是”5xx 多到重试兜不住了”。所以判据要是双条件:

错误率超过阈值 持续超过一段时间

两个条件缺一不可:

  • 只看错误率不看时长:一次一秒钟的抖动可能让瞬时错误率冲到 80%,然后立刻回落。这种毛刺不值得叫人。
  • 只看时长不看错误率:低水位的持续 5xx(比如常年 0.5%)是系统的背景噪声,不是事件。

一组可以拿来起步的思路(具体数值必须按你自己的流量特征和业务容忍度调,别照抄):

级别判据形状通知方式
记录任何 5xx只写日志,不通知
提示重试后仍失败的比例抬头,持续数分钟群消息,工作时间看
告警错误率明显超基线 持续数分钟值班响铃
紧急熔断器打开 备用通道也在失败立即升级

这个梯度里最有信息量的一层是 “重试后仍失败”。这个指标才真正代表用户感受到的失败——被重试成功消化掉的那些 5xx,用户根本不知道发生过。所以你的监控至少要分开记这两个数:

  • 原始 5xx 计数:用来判断上游健康度,喂给熔断器;
  • 最终失败计数:重试全部耗尽之后仍然失败的请求数,用来判断用户影响,喂给告警。

只记第一个,你会被噪声淹没;只记第二个,你会失去提前预警的能力(上游已经在恶化,只是重试还兜得住)。两个都要,用途不同。

另外记得给熔断器状态本身加一个监控项。熔断器打开是一个明确的、离散的、语义清晰的事件——它比任何百分比曲线都更适合作为告警触发点。

自查清单

  1. 按状态码分流:4xx(除 429)不重试、429 退避降速、5xx 短退避重试,三条路径在代码里是分开的,不是一个 except Exception 兜住。
  2. 5xx 和 429 用不同的重试参数:5xx 起点更短、次数更宽松;429 起点更长、必须带抖动,并配合全局降速。
  3. 确认你所用平台的 5xx 计费口径:Groq 官方明确 5xx 不计费,其他平台去各自官方文档或账单明细里核实一遍,别拿一家的口径套所有家。
  4. 熔断统计只计上游侧失败:4xx 和 429 不计入,否则一次参数写错就能把健康通道熔断掉。
  5. 对着所接平台的官方错误码清单过一遍自定义码:确认它们没有掉进”未知错误”兜底分支,被错误地判死或错误地告警。
  6. 分流只用状态码 + 错误体 typemessage 只进日志:搜一下代码里有没有对错误文案做关键词匹配的地方,有就改掉。
  7. 未知状态码按”上游侧问题”保守处理:记录、告警一次、可重试且计入熔断,而不是直接抛给用户。
  8. 告警用”错误率 + 持续时长”双条件,并且分开统计”原始 5xx”和”重试后仍失败”——前者判上游健康,后者判用户影响。
  9. 连续 5xx 达阈值就切通道,别死磕重试:重试解决抖动,切换解决故障,两件事不要混为一谈。

相关阅读