Skip to content

fix: 流空闲超时用 res.end() 收尾(不再 destroy 客户端连接)+ 采纳上游 statusCode + 显式 keepAliveTimeout - #43

Merged
MAXeaglet merged 4 commits into
MAXeaglet:masterfrom
xelr233:fix/stream-timeout-end
Sep 16, 2026
Merged

MAXeaglet merged 4 commits into
MAXeaglet:masterfrom
xelr233:fix/stream-timeout-end

Conversation

@xelr233

@xelr233 xelr233 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

修复 #42 中列出的代码侧问题。

来源是一次真实线上故障:1c2g VPS + OpenResty 反代,间歇性 502、客户端 connection error、
流式吐字明显变慢。资源指标全部健康(NRestarts=0、MemoryCurrent=215MB、
LimitNOFILE=524288、CPU 1.5%、无 OOM、ss 显示几乎无并发),所以不是容量问题。

反代 error.log:

sendfile() failed (32: Broken pipe) while sending request to upstream
    request: "POST /v1/chat/completions HTTP/1.1"
    upstream: "http://127.0.0.1:3050/v1/chat/completions"
upstream timed out (110: Connection timed out) while connecting to upstream

代理 journal:

Stream idle timeout {elapsedMs:62448,  bytesReceived:642575, lastCcEvent:"reasoning-delta"}
Stream idle timeout {elapsedMs:87201,  bytesReceived:688653, lastCcEvent:"text-delta"}
Stream idle timeout {elapsedMs:139706, bytesReceived:815758, lastCcEvent:"reasoning-delta"}
Stream idle timeout {elapsedMs:147005, bytesReceived:833893, lastCcEvent:"reasoning-delta"}

833KB / 147s ≈ 5.5KB/s —— 上游本来就慢(推理模型 + 容量受限),30s 空闲阈值确实会被触发。
问题不在"超时",在超时之后怎么收尾。


三个提交

① fix: 流空闲超时改用 res.end() 收尾 ← 本次的主问题

三处流式超时路径(OpenAI / Anthropic / Responses)原先都是:

res.write(\`data: \${JSON.stringify({ error: ... })}\\n\\n\`);
res.destroy();

res.write() 是异步的,紧接着 destroy() 会把尚未刷出的缓冲丢掉并发 RST:

时序 客户端看到
响应头尚未转发 502 Bad Gateway
已转发 connection error / 截断的流

代理本来是"主动截断并告知错误",实际变成了"把客户端连接搞断" —— 下游 SDK 本来能把
rate_limit_error 当可重试错误处理,现在只能吃一个连接层异常。

改为 res.end(errEvent):错误事件正常写进 SSE 流再发 FIN。
下游若已僵死(不读也不断),仍由 CLIENT_DRAIN_TIMEOUT_MS 那条路径强制断开,
职责不变(那里的 destroy() 故意保留)。

测试验证过有区分度:把 end() 换回 destroy() 时该用例失败,客户端拿到
TypeError: terminated;换回 end() 通过。用例自带一个"发一半就挂住"的上游。

② fix: 采纳上游 error 事件自带的 statusCode,并补齐无内容事件的静默列表

statusCode:CLI 的 readStreamErrorEvent 读的就是 error.statusCode / error.isRetryable,
取值链是 parseEmbeddedErrorJSON(message)?.status ?? error.statusCode ?? null。
原实现只看 message 里的 <NNN> 前缀 → 429/503 一律塌成 502,客户端不按限流退避,
监控也把它错归类成后端故障。

静默列表:上游每个响应都会发一串不携带内容的事件(text-start / text-end /
start / start-step / reasoning-start / reasoning-end / finish-step /
provider-metadata / tool-input-* / tool-error)。三条非流式路径里
OpenAI/Anthropic 缺一部分,Responses 非流式一个静默列表都没有 ——
于是 journalctl 被 Unknown CC event type 刷屏,真正的错误被淹没。

③ fix: 显式设置 server.keepAliveTimeout

proxy.mjs 从未设置过它,等于把「反代空闲超时 vs 后端空闲超时」的时序完全交给
Node 默认值(5s)与反代配置的巧合 —— 而这个项目的部署形态是已知的(README 里就是反代)。

规则:反代的 upstream keepalive_timeout 必须小于后端的 keepAliveTimeout。
反过来时反代会从缓存里取出一条后端已关闭的连接,写请求体时吃 EPIPE,
而 POST 非幂等、nginx 默认不重试 → 客户端直接 502。上面 error.log 里那 8 条
sendfile() failed (32: Broken pipe) 就是这一类(用 sendfile() 而非 writev(),
说明这些请求体大到被反代缓冲落盘)。

Node 默认 5s 与反代常见的 4s 只差 1 秒余量,而两边计时基准本就不同
(反代从"读完响应放回缓存"起算,后端从"写完响应"起算),大响应体下这点余量随时会被吃掉。

改为显式 keepAliveTimeout = 65s / headersTimeout = 66s
(CC_KEEPALIVE_TIMEOUT_MS 可覆盖),与 Node 官方"部署在反向代理之后"的建议一致。
启动横幅打出该值并提示反代侧对应项。


测试

node --test test/*.test.mjs   →  pass 7 / fail 0

新增 test/connection-lifecycle.test.mjs 7 条(三个提交共用):

# 用例
1-4 error.statusCode 映射:429→429+retry_after、503→503、无 statusCode→502、<NNN> 前缀优先
5 三协议 × 流式/非流式标准序列不产生任何 Unknown CC event type
6 流空闲超时用 end():已产生内容 + 错误事件完整送达(已验证可区分 destroy)
7 启动横幅打出 keepAliveTimeout 及反代侧提示

只带这一组测试所需的脚手架:test/helpers.mjs 与 package.json 的 test 脚本。
helpers.mjs 与 PR #34 / #39 中的文件 blob 完全一致(3342bb82),
三份先后合并都不会冲突 —— git 对「两侧新增同一路径且内容相同」不视为冲突。
因此本 PR 不依赖 #34 或 #39。


说明

反代 / 系统侧的配置调优(body 上限、keep-alive 值对齐、limit_conn、limit_rate 等)
不在本 PR 范围,属于部署方自行决定的事,issue #42 里也只列了代码侧问题。

第 ③ 条(keepAliveTimeout)也可以理解为"部署方自己该配",但它出现的原因是
proxy 把一个已知部署形态下的关键时序交给了默认值 —— 由 proxy 显式声明更合理,
所以一并修。若认为这属于部署方职责,单看前两个提交也成立。

本 PR 只带这一组测试所需的脚手架:test/helpers.mjs 与 package.json 的 test 脚本。
helpers.mjs 与 PR MAXeaglet#34 / MAXeaglet#39 中的文件**逐字节一致**(blob 3342bb8),
所以三份先后合并都不会冲突 —— git 对「两侧新增同一路径且内容相同」不视为冲突。
线上故障排查中发现的两个可观测性问题。

【一】mapCcEventError 丢掉 error.statusCode
CLI 的 readStreamErrorEvent 读的就是这个字段,取值链是
  parseEmbeddedErrorJSON(message)?.status ?? error.statusCode ?? null
原实现只看 message 里的 "<NNN>" 前缀,statusCode 一律被丢掉 → 一律塌成 502。

后果:上游报 429/503(限流、容量)时我们回 502 upstream_error ——
客户端不按限流退避,监控也把它错归类成后端故障。
线上那条 "The request limited providers for this model and they are currently
at capacity..." 就可能因此被记成 502 而不是 429
(按 CLI 的 isStreamErrorRetryable,该消息不含任何 terminal 标记
 premium_credits_exhausted / model_not_in_plan / insufficient credits,
 所以它是可重试的)。

改法:采纳 error.statusCode("<NNN>" 前缀仍优先,与 CLI 一致),
返回值增加 reportedStatus 以区分"上游报的"与"我们映射后的";
四个 CC error 日志点改为先映射再记日志,并打出
upstreamStatus / upstreamRetryable / code / mappedTo —— 与作者 78353d9
对 mapCcError 的处理保持一致。

【二】无内容事件的静默列表不全
上游每个响应都会发一串不携带内容的事件(text-start / text-end / start /
start-step / reasoning-start / reasoning-end / finish-step / provider-metadata /
tool-input-start|delta|end / tool-error)。三条非流式路径里:
  · OpenAI / Anthropic 非流式:缺 text-start / start / start-step / reasoning-start / finish-step
  · Responses 非流式:**一个静默列表都没有**
于是 journalctl 被 Unknown CC event type 刷屏,真正的错误被淹没。

改法:三处统一成同一份静默列表;default 仍保留警告,真正没见过的类型照旧留痕。

测试:新增 test/connection-lifecycle.test.mjs 5 条(statusCode 映射 4 条 +
三协议 × 流式/非流式不产生噪音 1 条)。
三处流式超时路径(OpenAI / Anthropic / Responses)原先都是同一个模式:

    res.write(\`data: \${JSON.stringify({ error: ... })}\\n\\n\`);
    res.destroy();

res.write() 是异步的,紧接着 destroy() 会把尚未刷出的缓冲丢掉并发 RST。
反向代理侧看到的就是"上游连接被重置":

    响应头尚未转发到客户端 → 502 Bad Gateway
    已转发                 → 客户端 connection error / 截断的流

也就是说:代理本来是"主动截断并告知错误",实际却变成了"把客户端连接搞断"。
下游 SDK 本来能把 rate_limit_error 当可重试错误处理,现在只能吃一个连接层异常。

线上现场(1c2g + OpenResty 反代,资源指标全部健康:NRestarts=0、
MemoryCurrent=215MB、LimitNOFILE=524288、CPU 1.5%、无 OOM):

    反代 error.log:
      sendfile() failed (32: Broken pipe) while sending request to upstream
      upstream timed out (110) while connecting to upstream
    代理 journal:
      Stream idle timeout {elapsedMs:147005, bytesReceived:833893, lastCcEvent:"reasoning-delta"}
      (833KB/147s ≈ 5.5KB/s —— 上游本来就慢,30s 空闲阈值确实会被触发;
        问题不在"超时",在超时之后怎么收尾)

改法:三处改为 res.end(errEvent) —— 把错误事件正常写进 SSE 流再发 FIN,
下游按可重试错误处理。下游若已僵死(不读也不断),仍由
CLIENT_DRAIN_TIMEOUT_MS 那条路径强制断开,职责不变(那里的 destroy 故意保留)。

测试:新增 1 条,并**验证过有区分度** —— 把 end() 换回 destroy() 时该用例失败,
客户端拿到 "TypeError: terminated"(连接被重置);换回 end() 通过。
用例自带一个"发一半就挂住"的上游,用 fetch().text() 是否成功即可判别两种收尾方式。
proxy.mjs 从未设置过 server.keepAliveTimeout,等于把「反代空闲超时 vs 后端空闲超时」
的时序完全交给 Node 默认值(5s)与反代配置的巧合。而这个项目的部署形态是已知的
(README 里就是 nginx / OpenResty 反代),不该靠巧合。

规则:反代的 upstream keepalive_timeout 必须**小于**后端的 keepAliveTimeout。
一旦反过来的,反代会从缓存里取出一条后端已关闭的连接,把请求体写过去 → EPIPE,
而 POST 是非幂等、nginx 默认不重试 → 客户端直接吃 502。线上 error.log 里那 8 条

    sendfile() failed (32: Broken pipe) while sending request to upstream

就是这一类。注意是 sendfile() 而非 writev(),说明这些请求体大到被反代缓冲落盘。

Node 默认 5s 与反代常见的 4s 只差 1 秒余量;而两边的计时基准本就不同
(反代从"读完响应放回缓存"起算,后端从"写完响应"起算)。大响应体(线上是
600~830KB 的流式响应)下这点余量随时会被吃掉。

改法:显式 server.keepAliveTimeout = 65s、server.headersTimeout = 66s
(CC_KEEPALIVE_TIMEOUT_MS 可覆盖),与 Node 官方"部署在反向代理之后"的建议一致
(keepAliveTimeout > 前端 idle timeout)。启动横幅打出该值并提示反代侧的对应项,
便于部署方对齐。

测试:新增 1 条,锁定"启动横幅必须打出 keepAliveTimeout 且提示反代对应项"。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants