结论先行:宿主侧那条每约 5 分钟刷一次的 MCPClient#onError streamable SSE stream disconnected: TypeError: terminated,根因不在网络抖动,而在服务端把 SSE 长连接的存活上限硬编码成了 300 秒——到点不打招呼直接掐断,客户端读到的不是干净 EOF,于是记为 terminated 并触发重连告警。
一、现象与初步排除
- 现象:客户端连接每约 5 分钟断一次,日志稳定复现
TypeError: terminated。 - 排除:网络层正常,反向代理未重启;服务端无异常堆栈,业务接口一切正常。
- 关键线索:断连周期稳定得可疑。既然每次都卡在差不多同一时刻,那大概率不是随机故障,而是某个超时/存活上限到点触发。
二、根因:两处硬编码的 300 秒
服务端有两条 SSE 长连接:
POST /mcp的subscriptions/listen订阅流(资源变更通知);- 兼容旧客户端的
GET反向流。
两条连接各有一个客户端拿不到的存活上限常量:STREAM_TTL_MS = 300_000L 与 BACKCHANNEL_TTL_MS = 300_000L。300 秒 ≈ 5 分钟,与观测到的 5 分 02 秒周期精确吻合,根因锁定。
掐断为什么会被客户端记成 terminated?因为到点时连接被容器强制关闭,响应体没有正常结束——对 undici/fetch 这类实现而言,这就是 TypeError: terminated(异常中断),而不是干净 EOF。
还有一个隐蔽坑:这些连接存活期内一个字节都不下发。只要反向代理的空闲读超时低于这个 TTL,连接会更早被代理切断——nginx 下对应的就是 proxy_read_timeout。
三、修复:三条一起做
- TTL 抽成配置:
plaza.mcp.stream-ttl-seconds(默认 300),不再硬编码。 - 周期性 SSE 注释心跳:每
plaza.mcp.stream-heartbeat-seconds(默认 15)秒写一行: keepalive。SSE 注释行不产生任何事件、客户端解析器直接忽略,只用于保活、维持中间设备上的连接活跃。 - 到点优雅收尾:Servlet 异步用
AsyncContext.complete(),SpringSseEmitter用emitter.complete()——让连接以干净 EOF 结束。客户端按需重连即可,不再报terminated。
心跳实现上有个小细节值得写下来:用单守护线程定时调度,心跳写入抛异常即自停(客户端已断开时不要对死连接空转),两条长连接共用同一调度器。
另外,心跳间隔必须小于反向代理的空闲读超时,否则心跳还没发出去,连接先被代理掐了。
四、怎么验证「收尾是否干净」
起一个真实容器(随机端口)做 E2E:把 TTL 压到 3s、心跳压到 1s,用 JDK HttpClient + BodyHandlers.ofInputStream() 把响应体读到 EOF:
- 若连接被掐断 →
InputStream#read抛IOException→ 断言失败(等价于宿主侧的terminated); - 能采到
: keepalive且读到流结束无异常 → 即为干净收尾。
修复后宿主侧不再刷 terminated 告警。
五、可迁移的经验
- 稳定周期 = 硬编码超时:日志里「准时」出现的故障,先去找常量,而不是先去查网络。
- 长连接要有心跳:哪怕服务端不主动推数据,也要定期写注释保活,别让中间设备替你决定生死。
- 超时要用「正常结束」而不是「掐断」:协议层凡是有收尾语义(
complete()/ EOF / 关闭帧),就用它,别让对端把正常关闭误判成故障。