stream disconnected before completion:先存原始错误,再判断是谁断了流
我保存过一段 Codex 的报错原文:
stream error: stream disconnected before completion: Transport error: error decoding response body; retrying 1/5 in 183ms…
stream error: stream disconnected before completion: Transport error: error decoding response body; retrying 2/5 in 404ms…
stream error: stream disconnected before completion: Transport error: error decoding response body; retrying 3/5 in 778ms…
stream error: stream disconnected before completion: Transport error: error decoding response body; retrying 4/5 in 1.614s…
stream error: stream disconnected before completion: Transport error: error decoding response body; retrying 5/5 in 3.41s…
stream disconnected before completion: Transport error: error decoding response body
这串文字比“工具不好用”有用,因为它保留了三个阶段:连接中断、解码响应体失败、五次重试仍然失败。但它仍然不能直接告诉我们是谁断的。本文讲的是怎么把证据补齐,而不是凭一行报错猜根因。
这句话能证明什么
能确认的事实有三条:
- 日志报告了响应体解码阶段的传输错误;
- 日志报告流在完成前断开,但没有显示服务端、代理或客户端各自的状态;
- 可见记录显示五次重试提示,最后仍报告同类错误。
这些文字本身不能证明服务器已经完整建立流、发出过哪些事件,或是否存在某个特定结束标记。
不能确认的是:中断发生在本地网络、代理、TLS 中间设备、服务端还是客户端解析器。仅凭 Transport error 也无法区分超时、连接被重置、响应被截断和协议不匹配。
为什么流式请求更容易暴露问题
下文以 SSE 作为排查长连接的例子;这段 Codex 报错本身不足以确定底层协议就是 SSE。MDN 的说明是(Using server-sent events,2026-10-30 取回):
The server-side script that sends events needs to respond using the MIME type
text/event-stream.By default, if the connection between the client and server closes, the connection is restarted.
长连接意味着中间任何一层都可以提前结束它:代理的空闲超时、网关的响应缓冲、VPN 的重新握手、服务端的滚动发布都会造成“没有正常结束”的观感。普通短请求可能一次性完成,流式请求则要把这些层逐段走完。
第一组证据:把现场固定下来
尽量记录以下字段;无法取得的项目标注缺失,便于下次比较:
时间(含时区):
执行的命令或操作:
工作目录:
客户端名称与版本:
操作系统与架构:
代理 / VPN / 终端环境变量:
原始错误全文(不要只截最后一行):
重试次数与间隔:
请求 ID / trace ID(如果响应头或日志里有):
同一时段其他网络请求是否正常:
原始错误要整段保存。只留最后一行,会丢掉重试次数、间隔和第一次失败的时间点;这些信息对判断“瞬时抖动”还是“稳定失败”很关键。
代理相关环境变量也要记录,例如 HTTP_PROXY、HTTPS_PROXY、NO_PROXY。很多“换了网络就好了”的案例,真正变化的是代理链路,而不是服务本身。
第二组证据:数据到底有没有到达
下面是 GET 流式端点的示意请求,用 curl 观察字节是否逐步出现;实际接口若采用 POST 或要求请求体,应按接口文档匹配方法、请求头和脱敏后的请求体:
# headers.txt 只放本次临时请求头,权限设为仅本人可读,用完删除
curl -N -D response_headers.txt \
-H @headers.txt \
"https://example.com/v1/stream"
不要把真实令牌直接写在命令行里。命令可能进入 shell 历史,也可能短暂出现在其他用户可见的进程列表里;请求头文件和响应头文件都要限制权限并在分享日志前脱敏,用完清理。避免直接公开 curl -v 输出,其中可能包含认证请求头。
-N 关闭 curl 自己的输出缓冲。长时间没有输出后一次性打印,可以把链路缓冲列为待查假设,也要核对服务端是否及时产生数据;收到部分事件后中断说明已收到部分响应,但仍需检查双方日志;连响应头都拿不到时,可先检查 DNS、TLS 和代理。
这一步不定位根因,但能分清“完全没通”和“通了一部分”,两者排查方向不同。
第三组证据:客户端与服务端各自看到什么
客户端侧看三件事:重试策略、超时设置、是否在重试前复用了已消费的请求体。流式请求体如果只能读一次,重试时可能发出空请求,表面看是“又失败了”,实际原因已经变了。
服务端侧看三件事:请求是否到达、响应状态码、连接关闭原因。能让服务端返回请求 ID 的,就把 ID 写进客户端日志;没有请求 ID 的,至少记录时间窗和客户端出口 IP,方便对方查日志。
如果服务端日志显示请求正常完成,而客户端仍报解码失败,可优先检查传输链路和客户端解析;如果服务端记录连接被客户端一侧提前关闭,可进一步核对客户端超时和代理日志。单条服务端记录仍不足以确定根因。
重试要有上限,也要有可读的最终错误
指数退避比固定间隔更合理,但必须有上限和终止条件。下面是一段伪代码,重点在结构,不在具体参数:
import time
MAX_ATTEMPTS = 3
BASE_DELAY = 0.5
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
return call_stream()
except TransportError as exc:
if attempt == MAX_ATTEMPTS:
raise RuntimeError(
f"stream failed after {MAX_ATTEMPTS} attempts; "
f"last_error={exc!r}; request_id={get_request_id()}"
) from exc
time.sleep(BASE_DELAY * (2 ** (attempt - 1)))
最终错误里要保留最后一次异常、请求 ID 和尝试次数。只抛一句“网络错误”,下游无法判断该重试、该换网络,还是该找服务方。
另外要区分可重试与不可重试:认证失败、参数错误、账户额度耗尽,重试不会变好;连接中断、超时和部分 5xx 可作为候选重试条件,但先确认请求是否幂等、服务端是否去重或原请求是否已经执行。限流是中间状态:如果响应是 429 并带有 Retry-After,应按对方给出的时间等待后再试,而不是用固定退避硬撞;如果额度已经用完,再重试只会得到同样的拒绝。把所有错误都塞进同一个重试循环,只会延长故障时间。
一张排查顺序表
| 观察 | 优先检查 | 暂时不能认定 |
|---|---|---|
| 连响应头都没有 | DNS、TLS、代理、出口网络 | 不能认定服务端故障 |
| 收到部分事件后中断 | 代理空闲超时、服务端关闭、客户端超时 | 不能认定数据格式错误 |
| 每次都在同一时间点失败 | 超时配置、固定大小的响应、网关限制 | 不能认定偶发网络抖动 |
| 换网络后恢复 | 代理、VPN、链路 MTU | 不能认定服务端已修复 |
| 只有流式接口失败,普通接口正常 | 缓冲、分块传输、长连接策略 | 不能认定账号或鉴权有问题 |
什么时候应该停下来
同一错误连续出现、重试次数已经用完、服务端明确返回不可重试状态时,继续重试只会制造更多噪声。此时应该保存证据、缩小到最小复现,再决定是改客户端、换链路还是联系服务方。
原始错误是排错的起点,不是结论。先把“谁在什么时候看到了什么”记录下来,后面的判断才有依据。
本文由 AI 助手依据本人历史素材起草;2025-10-02 的报错为当时粘贴的原始文本,本轮未复现、未确认根因;MDN 文档引用已在文中标注,命令与伪代码为排查示例。