Skip to content

Bug: 总供应商数: 0 个的时候一直等到超时结束 #1016

@xujiesh0510

Description

@xujiesh0510

响应时间过长:

0000ms] 会话复用选择供应商

Session ID: 55ecfbe2-495e-4e6f-8a36-673ead0f212c
复用供应商: tiger
供应商配置: 优先级=0 权重=5 成本倍数=1
基于会话缓存(5分钟内)

等待请求结果...

[0000ms] 首次选择供应商

系统状态:
总供应商数: 0 个
已启用 claude 类型: 0 个
健康检查通过: 0 个

✓ 选择: tiger

等待请求结果...

[128494ms] 请求失败(第 1 次尝试)

供应商: tiger
状态码: 524
错误: Provider returned 524:
请求耗时: 128494ms

熔断状态:
当前状态: 关闭(正常)
失败次数: 1/5
距离熔断还有 4 次

错误详情:

<title>bookapi.cc | 524: A timeout occurred</title> [255115ms] 供应商类型全端点超时(524)

供应商: tiger
状态码: 524
错误: Provider returned 524:
请求耗时: 126621ms

错误详情:

<title>bookapi.cc | 524: A timeout occurred</title> 该供应商类型的所有端点均超时,已触发供应商类型临时熔断。

[258740ms] cubence (client_abort)

==========================================================

claude code分析:


根因分析

你设置了 30s 流式超时(firstByteTimeoutStreamingMs = 30000),但实际等了 128 秒才超时。这是两条独立代码路径的问题:

路径分叉点(forwarder.ts:741)

send()
├─ shouldUseStreamingHedge() == true → sendStreamingWithHedge() ← 用 thresholdTimer
└─ shouldUseStreamingHedge() == false → 普通重试循环 ← 用 responseTimeout

shouldUseStreamingHedge 的触发条件(forwarder.ts:2951-2958)

(endpointPolicy?.allowRetry ?? true) &&
(endpointPolicy?.allowProviderSwitch ?? true) &&
stream === true &&
firstByteTimeoutStreamingMs > 0 // ← 你设置了 30s,所以走 hedge 路径

Hedge 路径里的 firstByteTimeoutMs 是干什么的(forwarder.ts:3117-3118)

const providerForRequest =
attempt.firstByteTimeoutMs > 0
? { ...attempt.provider, firstByteTimeoutStreamingMs: 0 } // ← 传给 doForward 时清零!
: attempt.provider;

Hedge 路径把 firstByteTimeoutStreamingMs 清零再传给 doForward,这样 doForward 内部的 responseTimeoutMs = 0,即禁用了 abort 超时(forwarder.ts:2334)。

Hedge 路径自己用 thresholdTimer(armAttemptThreshold)来触发"切换备用供应商",而不是中止当前请求。当没有其他可用供应商时(日志显示:总供应商数: 0,只有 tiger 一个),launchAlternative() 找不到备选,于是
当前的 tiger 请求就不受任何超时约束地一直跑下去,直到上游 Cloudflare 的 524(120s)。

为什么没有备选供应商?

日志里明确写了:
总供应商数: 0 个
已启用 claude 类型: 0 个
只有一个 tiger,launchAlternative 返回 null,Hedge 机制退化为"什么都不做",但此时 doForward 的 abort 已经被清零了,所以 30s 超时完全失效。

本质上是一个 bug

Hedge 路径的设计意图是:30s 内没有首字节 → 触发切换到备用供应商。但当没有备用供应商时,应该直接 abort 当前请求,而不是让它无限等待。当前代码在 launchAlternative 返回 null 后只是 return,没有 abort 掉
in-flight 的请求。

修复思路

在 launchAlternative() 确认没有备选供应商之后,如果当前还有 in-flight 请求,应该根据 firstByteTimeoutMs 给它们设一个兜底 abort(或者干脆在 shouldUseStreamingHedge 里加个"至少有 2 个供应商"的前置条件)。

最简单的修复在 launchAlternative(forwarder.ts:3080-3086):

if (!alternativeProvider) {
noMoreProviders = true;
if (attempts.size === 0) {
await finishIfExhausted();
} else {
// 没有备选时,给所有 in-flight 请求补上首字节超时兜底 abort
for (const attempt of Array.from(attempts)) {
if (attempt.firstByteTimeoutMs > 0 && !attempt.settled) {
attempt.responseController?.abort();
}
}
}
return;
}

不过更彻底的修复是在 runAttempt 里:当 noMoreProviders 确认后,如果 thresholdTriggered 但没有 winner,应把当前请求的 abort 恢复。你可以根据实际架构选择合适的修复层次。

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:providerbugSomething isn't workingoncallCritical blocking issue requiring immediate oncall attention

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions