接口超时、连接被重置:什么该重试,什么一重试就烧钱
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
超时不是失败,超时是结果未知。 大多数人把它归成”服务不稳定”,然后在客户端加一层重试就算处理完了。但读超时的真实含义是:你的请求很可能已经完整地送到对端、对端很可能已经在算了,只是回包没有在你设定的时间内回来。这时候你重试一次,对端就可能做了两次——两次都在消耗你的输入,两次都可能落库、都可能触发工具调用。所以处理超时的第一步不是加重试,而是判断这次请求到底走到了哪一步;只有走不到”服务端开始处理”这一格的失败,才是可以无脑重试的。
站内已有三篇相邻的文章,分工先说清楚:DeepSeek API 报 429 和 GLM API 报 429 讲的是明确带状态码的限流,服务端已经明确告诉你”太快了”;国产大模型 API 报错横评 讲的是各家报错口径的差异对照。本篇不碰状态码语义的比较,只处理最难判的那一类——连接层和传输层的失败,也就是你压根没拿到状态码的那些情况:超时、连接被重置、握手失败、流中途断掉。
一、把”断”拆成四格,才有排查顺序
一次 HTTPS 请求到大模型接口,粗看是一次调用,实际上至少分成五段:DNS 解析、TCP 连接、TLS 握手、把请求体发出去、等回包(流式的话还要分首字节和后续增量)。断在哪一段,性质完全不同。
先在脑子里建立这四格:
- 第一格,还没连上。 DNS 解析不出来、TCP 连不上、握手失败。这一格的特征是几乎不消耗对端资源,你的请求内容一个字节都没进入模型。这一格失败,重试是安全的——但安全不等于有用:网络抖动、对端某个节点短暂不可达这类偶发,退避重试确实能自愈;而证书链校验不过、DNS 里压根没这条记录这类确定性失败,重试一万次也是同一个结果,得去改配置。所以这一格的规则是”可以重试,但要给次数上限,并且把确定性错误单独识别出来”。
- 第二格,连上了但请求没发完。 大输入的场景比较常见,尤其是把整个代码目录塞进去的那种。这一格通常也可以重试,但要注意它经常是链路 MTU、代理缓冲、上传带宽的问题,重试同样的包大小往往同样失败。
- 第三格,请求发完了,回包没来。 这是最危险的一格,也就是典型的”读超时”。结果未知,重试等于可能做第二遍。
- 第四格,流式响应中途断。 已经吐了一部分内容,然后连接掉了。这一格的问题是你手上有半个结果,重试会拿到另一个完整结果,而已经吐出来的那半个也已经消耗掉了。
判断自己在哪一格,最省事的办法是看时间分布。用 curl 打一次同样的地址,把各段耗时单独打出来:
curl -sS -o /dev/null --connect-timeout 5 --max-time 30 \
-w 'dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
https://your-endpoint.example.com/v1/models
两个超时参数别省:不带 --connect-timeout 时 curl 的连接等待很长,你会以为是”卡住了”其实只是还没到它的默认上限;--max-time 则保证这条诊断命令自己不会挂住。不用管接口返回什么内容,401 也不影响——这一步只看时间分布。
读法要点在于:这几个计时是累计值,且没走到的阶段会打印 0,不是不打印。所以判断规则是”看第一个变成 0 的位置”:
conn是 0 而total恰好等于你设的--connect-timeout(或 curl 直接报 exit code 28):连接从未建立,问题在网络出口、路由或对端可达性。conn有值但tls是 0 或直接报证书错:TCP 通了、TLS 没过,去看证书链和链路上的中间设备。tls有值而ttfb明显大于tls:连接和握手都正常,慢在服务端排队或者你的请求体太大发得久。
这三种在应用日志里可能都写成一句”timeout”,但处置动作完全不同。多打几次看分布,别拿单次结果下结论——偶发一次和每次都这样,是两个问题。
顺手提一句区域限制。部分海外厂商的接口对中国大陆等区域设有服务范围或访问限制,具体是否可用、以什么方式接入,以该厂商官方条款和最新说明为准,本篇不提供任何绕开限制的方法或渠道。这里只说与排查有关的那部分事实:一旦你的链路里出现了不受你控制的中转跳数,就等于多了一处超时来源、多一处连接被重置的可能,而那一跳的稳定性、超时设置、缓冲行为全都不在你的可观测范围内。
这件事对排查方法的影响是实质性的:判别表上那些验证动作,前提是”链路两端都在你视野里”。少了这个前提,你在客户端能做的调参很快就会见底,日志上呈现出的现象也会失真——中转方自己的读超时先触发,你收到的就是一个连接被重置,而对端可能早就正常返回了。所以合规、稳定的接入方式本身就是可观测性的一部分,选择上有余地的话优先选能拿到端到端可见性的那条路。
二、现象判别表
下面这张表按”你在日志里看到什么”来查,不按厂商分。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| ETIMEDOUT 且发生在连接阶段 | 出口网络不通、对端不可达、被中间设备丢包 | curl 看 time_connect 是否为 0 且 total 等于 --connect-timeout(退出码 28);换一条网络(手机热点)复现 | 可以重试,指数退避并设次数上限;同时查代理与 DNS 配置 |
| ECONNRESET / 连接被对端重置 | 中间设备主动断、代理超时回收、请求体过大被截 | 缩小输入重试一次;对比小请求是否稳定 | 小请求正常则按大输入处理,拆请求而不是加重试次数 |
| TLS 握手失败,提示证书链校验不过 | 链路里有做 TLS 拦截的设备或代理,换发了自签证书 | 直接看你实际拿到的证书是谁签的(下方 openssl 命令);换一条网络对比签发者是否变化 | 把对应 CA 装进系统与运行时的信任库,或换一条不做拦截的网络,绝不要关校验了事 |
| 请求发出后长时间无响应,最终读超时 | 服务端在排队或输出很长,客户端读超时设得太短 | 把读超时放宽一次做对照;改用流式看是否有首字节 | 调整读超时或改流式,不要直接重试非幂等请求 |
| 流式响应吐了一部分后断开 | 中间层空闲回收、网关缓冲、客户端未及时消费 | 看断开前是否有较长的静默间隔 | 加空闲超时与断点续接逻辑,别整体重发 |
| 返回 401 / 403 | 凭据失效、权限不足、来源限制 | 换一个已知可用的密钥打同一接口 | 一次都不要重试,直接报错给上层 |
| 返回 429 | 限流或配额,语义各家不同 | 看响应头与响应体给出的提示 | 按退避重试;细节见上文那两篇限流专题 |
| 返回 500 / 502 / 503 / 504 | 服务端或网关侧异常 | 观察是否集中在某个时间窗、是否所有请求都中 | 502/503/504 可退避重试;500 先看是否为固定输入必然触发 |
表里 TLS 那一行的验证动作值得单独给出来,因为它能一条命令定性,不用猜:
openssl s_client -connect your-endpoint.example.com:443 \
-servername your-endpoint.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates
看输出里的 issuer。如果签发者是一个你眼熟的公共 CA,那证书本身没问题,报错八成出在运行时自带的 CA 包过旧或环境变量指错了路径;如果签发者是公司内网设备、某个安全网关的名字,那就确认无疑——链路上有设备在做 TLS 拦截,你的流量被解密再加密了一遍。这两种情况的处置完全不同,而应用日志里给你的都只是一句证书校验失败。顺带看一眼 notAfter,过期证书也是这类报错的常见来源之一。
表里还有一条容易被忽略:401/403 绝不重试。这类错误重试一百次也是同样结果,但很多客户端的重试拦截器是按”异常”而不是按”状态码语义”配的,于是密钥一过期就出现日志被刷爆、账单曲线抖动的场面。凭据失效这件事该在监控里单独成一类,别混进”网络不稳”的那一堆。
三、重试纪律:白名单、黑名单、以及退避怎么写
我的习惯是把重试写成白名单制,而不是黑名单制。默认不重试,只有明确判定属于以下情况才重试:
可以重试:DNS 与连接阶段的失败;TLS 握手层面的偶发失败;请求体尚未发完就断开;网关层的 502、503、504;带退避的 429;以及任何你自己确认过是幂等的读操作。
不要重试:400 这类参数错误(同样的输入必然同样报错);401、403;请求体已发完之后的读超时,除非你带了幂等保障;以及任何一次调用背后会产生副作用的操作,比如在 agent 流程里那一步会真的写文件、发消息、提交代码。
重试就是烧钱的四种情况,单独拎出来说,因为它们不会报错,只会在账单上出现:
一是大输入整体重发。你把一大堆上下文塞进去,读超时了,重试一次就是把同样的输入再消耗一遍。对端很可能第一遍已经处理完了。
二是流式中断后从头重来。已经产出的那部分白扔,再来一遍全量。
三是批量任务整批重跑。批里有一条失败,脚本简单粗暴地整批重试。正确做法是记录每条的完成状态,只补失败项。
四是重试套在了错误的层级。这是我见过最贵的一种:agent 的外层循环里包了一个重试,而内层的工具调用本身也带重试。一次网络抖动,指数级放大成十几次真实调用,工具副作用也跟着执行了十几次。重试只应该存在于最贴近网络的那一层,业务循环层做的是判断”这次任务成不成”,不是”再来一次”。
至于退避怎么写,机制上就三件事:指数增长、加随机抖动、设总预算。抖动不能省,一批客户端同时被打断又同时按同一节奏重试,就是自己给自己造一次并发尖峰。总预算指的是”这次调用最多允许花多长时间和多少次”,超了就直接失败上报,而不是无限退避到天荒地老——因为对上游来说,一个迟迟不返回的调用比一个明确失败的调用更难处理。
顺便说清超时本身的设法:连接超时和读超时必须分开设。连接超时可以设得短,因为连不上就是连不上,等下去没意义;读超时要设得宽,因为模型输出长度天然不确定。用 Python 的话,写法大致是这个形状(具体数值按你的场景定,不同任务差别很大):
import httpx
# 四个值全部显式给出:httpx 要求要么给一个默认值,要么四个参数都设,
# 只写 connect 和 read 会直接抛错。下面四个常量按你自己的场景定义。
timeout = httpx.Timeout(
connect=CONNECT_SECONDS, # 连接:短
read=READ_SECONDS, # 读:宽,跟着任务类型分档
write=WRITE_SECONDS, # 写:大输入场景要留够
pool=POOL_SECONDS, # 等连接池里空闲连接的时间
)
client = httpx.Client(timeout=timeout)
pool 这一项最容易被忽略,它超时的含义和其他三个完全不同:不是对端慢,是你自己的连接池被占满了,请求连出去的机会都没排到。它频繁超时时该做的是降并发或加大池子,而不是延长任何超时——这也是为什么日志里必须记清”超时发生在哪个阶段”,同一个词背后是四种完全不同的处置。
如果你的请求经常触及读超时,比较稳的做法是改用流式:流式下你可以把”整体读超时”换成”首字节超时 + 空闲超时”。首字节迟迟不来说明在排队,可以早失败;有增量在持续到达就一直等下去,不再因为总时长而误杀一个正在正常输出的请求。这个改动往往比调大读超时更有效,因为它区分了”卡住”和”输出长”。
四、幂等怎么保:不靠猜,靠键
读超时之后要不要重试,本质上取决于一件事:重复执行有没有代价。让重复执行没有代价,就是幂等设计。
三层做法,从外到内:
第一层,请求级幂等键。 由调用方生成一个稳定的唯一标识随请求带上。注意”稳定”的含义:重试的时候必须复用同一个键,而不是重新生成。所以键要在进入重试循环之前就算好。业界常见的做法是放在请求头里,具体的头名各家接口不一致,接入前查对应接口的说明。如果对端不支持这类语义,就靠下面两层。
第二层,本地去重表。 在你自己这侧建一张表,主键就是那个幂等键,状态字段记 pending / done / failed,落库用唯一约束兜底。请求前先插入 pending,插入冲突就说明这次是重试;请求成功后写 done 并把结果一起存下来。这样即使对端做了两遍,你这侧对外只会呈现一份结果。
冲突之后怎么走,要分状态看,这里最容易写错:
- 已是
done:直接返回存好的结果,一次都不用再调。 - 已是
failed:按前面的白名单判断这个失败类型该不该重试,该重试就把状态改回pending再发。 - 仍是
pending:说明上一次的结果未知,这才是读超时的典型现场。此时最稳的动作不是重发,而是先看对端有没有查询手段(有些接口支持按你带的标识回查一次),没有就等一个宽限期再决定;实在要重发,也得确认自己带的是同一个幂等键。把pending无脑当成”上次没成”来重发,去重表就白建了。
配套还要有一条:给 pending 设置过期时间并记上开始时间,否则进程崩在中间会留下永久 pending,把这条业务卡死。
键怎么算?用业务上真正决定”这是同一件事”的字段做哈希,别用时间戳、别用随机数:
import hashlib, json
def idem_key(task_id: str, payload: dict) -> str:
body = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(f"{task_id}|{body}".encode("utf-8")).hexdigest()
第三层,副作用后置。 把”调用模型”和”产生副作用”分成两步。模型调用只负责产出结果并落库,落库成功之后再由另一个步骤去执行写文件、发通知、提交代码这些动作,并且这些动作自己也带幂等判断(比如提交前先看目标状态是否已经是期望值)。这样即使模型那一步被重复执行,副作用也只发生一次。
这一层在 agent 场景里尤其关键。一个带工具的循环,如果工具执行和模型调用混在同一个不可分割的重试单元里,任何一次网络抖动都会把已经执行过的工具动作再执行一遍。所以 agent 的每一步都要能单独判断”这一步做过了没有”,而不是靠”整轮重来”来纠错。
还有一个反直觉的点:对方是否对中断的请求计费,各家规则不同且会调整,以官方最新说明为准。 不要基于”反正断了应该不收钱”来设计重试策略。把它当成”可能收”,你的设计才是安全的;如果实际不收,那只是白赚。想搞清自己到底被重试吃掉了多少,得有调用侧的记账,做法见API 成本监控。
五、什么情况下别再折腾
排查这类问题最大的成本不是修不好,是修得太久。给自己划三条线。
止损线一:时间盒。 给单次排查定一个明确的时间上限,比如一个下午。到点还没定位到是哪一格(连不上 / 发不出 / 回不来),就停手切备用路径,把定位工作转成后台任务。因为在这类问题上,投入时间和收敛概率不是线性关系——判别表上那几个验证动作跑完还没结论,通常意味着问题在你观测不到的那一跳,继续在客户端调参数不会有进展。
止损线二:失败率熔断。 同一个接口在一个滚动窗口里失败率持续偏高,就熔断掉,别让请求继续排队重试。熔断的意义不只是保护对端,更是保护你自己的线程池和队列:大量在途的重试请求会把连接池占满,让本来能成功的请求也开始超时,看起来像”整个服务都挂了”,其实是自己把自己堵死的。
回滚点在哪。 先回滚最近改过的东西,顺序建议是:客户端 SDK 版本、代理与出口配置、超时与并发参数、请求体大小。这四项里任何一项在故障发生前一天动过,都优先怀疑它。回滚是最快的验证手段,比读代码猜快得多。
换条路的判断依据。 当以下任一条成立,就该走多路径而不是继续修单路径:一,验证发现同样的请求在另一条网络下稳定成功(说明问题在链路,不在你的代码);二,故障集中在某个时间段规律性出现(说明在对端或中间层的容量周期上,你改不了);三,业务不能等。这时候把请求路由到备用模型或备用接入点,是成本最低的处置。多路径怎么设计不至于变成”两条路一起烂”,多模型 fallback 设计那篇有具体结构。
反过来也有明确不该换路的情况:如果失败是 400、401、403 这类明确的语义错误,换路只是把同一个错误在另一个地方再报一次,纯浪费。换路只解决可用性问题,不解决正确性问题。
六、避坑清单
坑一:用一个全局超时值糊住所有调用。 为什么会踩:框架默认就给一个 timeout 参数,最省事。怎么避:至少把连接超时和读超时分开;再按任务类型分档,短问答和长文生成不该共用一个值,否则你只能在”误杀长任务”和”卡死短任务”之间二选一。
坑二:把重试写在业务循环外层。 为什么会踩:外层加重试改动最小、看起来最通用。怎么避:重试只放在 HTTP 客户端那一层,业务层收到失败就是失败,由状态机决定下一步。判断标准很简单——如果一次重试会导致某个工具动作被执行两遍,那这个重试的位置就是错的。
坑三:重试时重新生成幂等键。 为什么会踩:键的生成通常写在”构造请求”的函数里,而重试往往会重新走一遍构造。怎么避:把键的计算提到重试循环之外,作为参数传进去;写单测覆盖”同一任务重试三次,键相同”。
坑四:TLS 报错时直接关掉证书校验。 为什么会踩:搜到的第一个答案往往就是关校验,而且立刻就通了。怎么避:证书链校验失败在企业网里通常是因为链路上有做 TLS 拦截的设备用了自签证书,正确处置是把对应的 CA 装进系统和运行时的信任库,或者换一条不做拦截的网络。关掉校验意味着你的密钥和全部请求内容在这条链路上对中间设备是可见的,这个代价和它省下的十分钟完全不成比例。
坑五:把所有异常都归成”超时”记日志。 为什么会踩:捕获一个大而全的异常类型最简单,日志里只留一句 message。怎么避:日志里至少记四个字段——异常类名、发生在哪个阶段(连接/发送/接收)、已耗时、以及那个幂等键。没有这四个字段,事后你没法判断当时在哪一格,只能靠复现,而这类问题最难的恰恰就是复现。
坑六:并发上不封顶,靠重试兜。 为什么会踩:把并发调高看起来能提吞吐,偶发失败反正有重试。怎么避:并发要有明确上限并且和连接池大小对齐;一旦开始出现超时,先降并发再谈别的。高并发下的超时和限流经常同时出现,先把并发压下来,你才能分清哪个是因、哪个是果。
坑七:流式响应不设空闲超时。 为什么会踩:流式看起来一直在”进行中”,没人想到它可能已经死了。怎么避:给两个门槛——首字节等待上限,和相邻增量之间的最大静默间隔。后者才是真正能识别”连接已经悄悄断了但还没报错”的那一道。
收个尾
处理超时和中断,难点从来不在写重试代码,在于承认一件事:这类失败的结果是未知的,而未知不能靠重试消除,只能靠幂等消化。把判断顺序固定下来——先看断在哪一格,再查表定性质,再决定重试或止损——这类问题就从”玄学”变成流程。
下次线上再出这种问题,按这五条自检:
- 这次失败发生在连接、发送、接收哪一段?日志里能直接看出来吗?
- 这个请求是幂等的吗?幂等键在重试时复用了吗?
- 重试逻辑在哪一层?会不会把某个副作用执行两遍?
- 连接超时和读超时是分开设的吗?流式有空闲超时吗?
- 我已经排查多久了?该切备用路径了吗?
前四条决定你会不会白花钱,第五条决定你会不会白花时间。