-
-
Notifications
You must be signed in to change notification settings - Fork 181
Closed
Labels
bugSomething isn't workingSomething isn't working
Milestone
Description
Originally posted by @RWDai in #229
📋 问题描述
当供应商首字节超时触发故障转移后,约 60 秒后会在日志中出现误导性的 499 错误("Response transmission aborted")。虽然此错误不影响实际业务(请求已成功通过第二个供应商完成),但会造成日志污染、资源浪费和潜在的连接池耗尽风险。
🔍 问题现象
症状
- 故障转移成功,客户端正常收到响应
- 约 60 秒后,日志中出现
ProxyError: Response transmission aborted(状态码 499) - 错误来源于已被中止的第一个供应商的延迟清理
日志证据
2025-12-06T16:35:34.393Z - 请求开始,选择供应商 xyai-2025/12/8 16:11:34 (ID=2)
2025-12-06T16:35:38.588Z - [WARN] ProxyForwarder: Response timeout (首字节超时 20s)
2025-12-06T16:35:38.589Z - [ERROR] ProxyForwarder: Response timeout (provider quality issue, will switch)
2025-12-06T16:35:38.594Z - [INFO] ProxyForwarder: Switched to alternative provider (WONG, ID=7)
2025-12-06T16:35:42.991Z - [INFO] ProxyForwarder: Request successful (第二个供应商成功)
2025-12-06T16:35:45.496Z - [DEBUG] Task completed successfully (响应完整传输)
⚠️ 约 60 秒后:
2025-12-06T16:36:34.368Z - [WARN] ProxyForwarder: Request/response aborted
2025-12-06T16:36:34.368Z - [ERROR] Proxy handler error: "Response transmission aborted" (statusCode=499)
时间线分析
| 时间偏移 | 事件 | 说明 |
|---|---|---|
| T+0s (16:35:34) | 请求开始 | 选择第一个供应商 xyai |
| T+4s (16:35:38) | 首字节超时 | responseController.abort() 被调用 |
| T+4s (16:35:38) | 故障转移 | 立即切换到第二个供应商 WONG |
| T+8s (16:35:42) | 获取响应头 | 第二个供应商成功响应 |
| T+11s (16:35:45) | 响应完成 | 客户端收到完整数据 |
| T+60s (16:36:34) | 延迟错误 | 第一个供应商的连接终于被清理,抛出 499 错误 |
🔧 根因分析
问题定位
在 src/app/v1/_lib/proxy/forwarder.ts 中:
-
超时触发位置 (行 932-942)
if (responseTimeoutMs > 0) { responseTimeoutId = setTimeout(() => { responseController.abort(); // ⚠️ 只中止 fetch,但底层 TCP 连接未立即清理 logger.warn("ProxyForwarder: Response timeout", { providerId: provider.id, providerName: provider.name, responseTimeoutMs, responseTimeoutType, isStreaming, }); }, responseTimeoutMs); }
-
延迟错误捕获位置 (行 1162-1179)
if (isClientAbortError(err)) { logger.warn("ProxyForwarder: Request/response aborted", { providerId: provider.id, providerName: provider.name, proxyUrl: new URL(proxyUrl).origin, errorName: err.name, errorMessage: err.message || "(empty message)", errorCode: err.code || "N/A", }); throw new ProxyError( err.name === "ResponseAborted" ? "Response transmission aborted" // ⚠️ 误导性错误消息 : "Request aborted by client", 499 ); }
技术原因
这是 undici fetch 实现的异步清理延迟问题:
-
AbortController 行为
responseController.abort()会立即中止 fetch 操作- 抛出 ProxyError (524) 并触发故障转移
- 但底层的 HTTP 连接(TCP/TLS)并未立即关闭
-
连接池保持
- undici 的 Agent 连接池保留了第一个供应商的连接
- 这个连接仍在等待服务器响应或超时
- 约 60 秒后(可能是 TCP keepalive 或服务器超时),连接终于被清理
-
孤儿错误
- 当底层连接最终关闭时,触发
ResponseAborted错误 - 此时请求已完成,这个错误成为"孤儿"
- 被错误地归类为客户端中止 (499)
- 当底层连接最终关闭时,触发
📊 影响范围
严重程度:中等 (Medium)
功能影响
- ✅ 不影响业务功能:故障转移正常工作,客户端正常收到响应
- ❌ 日志污染:产生误导性的 499 错误,干扰监控和告警
- ❌ 资源浪费:第一个供应商的连接挂起约 60 秒才被清理
潜在风险
- 连接池耗尽:高频故障转移场景下,可能耗尽 undici 连接池
- 监控误判:499 错误可能触发不必要的告警
- 排查困难:日志中的 499 错误与实际客户端中断混淆
触发条件
- 供应商首字节超时 (
firstByteTimeoutStreamingMs或requestTimeoutNonStreamingMs) - 成功故障转移到备用供应商
- 第一个供应商的连接保持活跃约 60 秒
💡 建议的解决方案
方案 1: 显式清理 Agent 连接 ⭐ (推荐)
优点: 彻底解决问题,立即释放资源
缺点: 需要修改 forwarder 逻辑
实现思路:
// 在 forwarder.ts 中记录 agent 引用
const agentToCleanup = proxyConfig?.agent || (init.dispatcher as Agent);
responseTimeoutId = setTimeout(() => {
responseController.abort();
// 主动销毁底层连接
if (agentToCleanup && 'destroy' in agentToCleanup) {
try {
(agentToCleanup as Agent).destroy();
logger.debug("ProxyForwarder: Agent destroyed on timeout", {
providerId: provider.id,
});
} catch (err) {
logger.warn("ProxyForwarder: Failed to destroy agent", { err });
}
}
logger.warn("ProxyForwarder: Response timeout and connection cleaned", {
providerId: provider.id,
providerName: provider.name,
responseTimeoutMs,
responseTimeoutType,
isStreaming,
});
}, responseTimeoutMs);修改位置: src/app/v1/_lib/proxy/forwarder.ts:932-942
方案 2: 识别并忽略孤儿错误
优点: 最小化代码修改
缺点: 治标不治本,连接仍会挂起 60 秒
实现思路:
// 在 forwarder.ts 中记录成功时间
let lastSuccessTime: number | null = null;
// 成功时记录
if (response) {
lastSuccessTime = Date.now();
}
// 错误处理时检测
if (isClientAbortError(err)) {
// 检查是否是延迟的清理错误(请求已完成 > 5 秒)
if (err.name === "ResponseAborted" && lastSuccessTime &&
(Date.now() - lastSuccessTime > 5000)) {
logger.debug("ProxyForwarder: Ignoring delayed connection cleanup", {
providerId: provider.id,
timeSinceSuccess: Date.now() - lastSuccessTime,
});
return; // 静默忽略,不抛出 499 错误
}
// 真正的客户端中断才抛出错误
throw new ProxyError(
err.name === "ResponseAborted"
? "Response transmission aborted"
: "Request aborted by client",
499
);
}修改位置: src/app/v1/_lib/proxy/forwarder.ts:1162-1179
方案 3: 调整超时配置 (临时缓解)
优点: 无需代码修改
缺点: 只能减少触发频率,不能解决根本问题
配置调整:
# 增加首字节超时时间(从 20s 提高到 30s)
FIRST_BYTE_TIMEOUT_STREAMING_MS=30000
# 或者在供应商管理页面单独配置慢速供应商的超时🧪 复现步骤
- 配置两个供应商,第一个供应商设置较短的首字节超时(如 20 秒)
- 确保第一个供应商响应缓慢(真实超时或人为延迟)
- 发送流式请求(如 Claude messages API)
- 观察日志:
- T+20s: 首字节超时,故障转移
- T+25s: 第二个供应商成功响应
- T+80s: 出现 499 错误
📝 环境信息
- 部署方式: Docker Compose
- Claude Code Hub 版本: (请提供 git commit hash 或 release tag)
- Node.js 版本: 20+ (Docker 镜像内)
- undici 版本: (Next.js 15 内置)
- 相关配置:
firstByteTimeoutStreamingMs: 20000 (20秒)ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS: falseMAX_RETRY_ATTEMPTS_DEFAULT: 2
🔗 相关文件
src/app/v1/_lib/proxy/forwarder.ts(核心问题代码)src/app/v1/_lib/proxy/errors.ts(错误分类逻辑)src/lib/proxy-agent.ts(代理 Agent 创建)
✅ 验收标准
- 供应商超时触发故障转移后,不再出现延迟的 499 错误
- 第一个供应商的连接在超时后立即被清理(或在 5 秒内)
- 日志中的 499 错误仅表示真正的客户端中断
- 不影响现有的故障转移和熔断器功能
🏷️ 标签建议
bug- 代码缺陷priority: medium- 中等优先级area: proxy- 代理转发模块good first issue(如果选择方案 2 或 3)
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't working
Projects
Status
Done
