Skip to content

fix(web-search): 连接层瞬时失败重试,并让 fetch failed 带原因与目标(#117) - #118

Merged
Mars-Sea merged 3 commits into
mainfrom
agent/investigate-web-search-failures-3cdfd1f3408a
Nov 3, 2026
Merged

Mars-Sea merged 3 commits into
mainfrom
agent/investigate-web-search-failures-3cdfd1f3408a

Conversation

@ben-bot-ai

@ben-bot-ai ben-bot-ai commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

This is by an automated agent, not a human.

调查结论

issue #117 的 fetch failed 发生在拿到 HTTP 状态之前,因此不是 401/403/429,也不是套餐或凭据结论。它由两个独立事实叠加:

  1. undici 的 fetch 把连接/读取阶段失败统一包成裸的 TypeError: fetch failed,真实原因码在 error.cause。本机对不可达地址实测 10.5 s 后为 UND_ERR_CONNECT_TIMEOUT,双地址族各等一轮可到约 20 s,与报告者面板的 21.738 s 同量级。
  2. 本插件的搜索此前只发一次 fetch、失败即抛;DSH 的 dsh-tool-web 在多 query 时第一个失败就 abort 同批其余请求并整体抛出。一次连接抖动因此丢掉整批 query 的结果。

不涉及本仓库的两个可能因素也已排除:默认 apiBase 无反代前缀(#111 不适用),报告环境无代理变量(#59 不适用)。

改动

  • CommandCodeSearchProvider.search() 只对「未收到 HTTP 响应」的连接层失败重试:最多 3 次尝试(首发 + 2 次),退避 500 ms → 1 s、±20% 抖动;账号在进入重试前只解析一次。

  • 重试等待监听调用方信号:用户取消或工具协程超时仍以 WEB_ABORTED 结束,不会睡满退避。

  • 已收到 HTTP 状态(含 401/403/429)不重试——那是服务端的答案;响应体失败也不重试,因为响应头已到、请求已被接受并可能执行,重放有重复计费的代价。所有非取消的 fetch 拒绝都按瞬时处理(含证书、参数类确定性错误),代价是最多约 1.5 s 后抛出同一错误。

  • 最终错误追加 error.cause 原因码(复用 transportFailureReason())、目标 origin(httpOrigin() 丢弃路径与查询,不回显反代前缀或凭据)、耗时与尝试次数。示例:

    Command Code web search request failed: fetch failed(连接中断(UND_ERR_SOCKET);目标 http://127.0.0.1:58317;耗时 1693ms;已尝试 3 次)
    
  • 同步 CHANGELOG.md、docs/agent-reference/accounts-and-retry.md 与 docs/决策记录.md 第十一章第 3 条,并重建 lib/。

未改动 / 边界

  • 多 query 部分成功:abort-on-first-failure 策略在 DSH 的 dsh-tool-web,不在本插件;要返回部分结果需上游修改。
  • 可配置搜索超时与 keep-alive:issue 中列为可选项。前者是新的用户可见契约,后者尚无证据表明连接复用是瓶颈,本轮不做。
  • 重试只覆盖「未收到响应」,因此一次上游已处理但响应丢失的搜索可能被重放;搜索只读且幂等,接受这一代价。
  • 重试预算与工具预算贴边:慢失败时 3 次尝试可能超过 dsh-tool-web 默认 30 s 的 searchTimeoutMs,工具 deadline 落在重试期间则按取消(WEB_ABORTED)上报,最后一次连接层原因不会出现在错误串里。完整算术与取舍见 docs/agent-reference/accounts-and-retry.md;未加每次尝试超时(新的用户可见契约),也未按已耗时收敛(会跳过本 issue 的慢失败场景)。

验证

  • npm run typecheck 退出码 0。
  • npm test 1163/1163(tests/web-search.test.ts 35/35);新增 7 条:第二次尝试成功且密钥只解析一次、3 次后停止并带原因/目标/次数、403 不重试、注入生产等待函数的取消用例、默认 retryWait ?? searchRetryWait 接线的取消用例(fake timers + 真实 guard 定时器)、退避基数与抖动边界、传给等待函数的 500 ms→1 s 延迟序列。
  • npm run build 两段 Build complete,git diff --exit-code -- lib/ 干净。
  • 真实 HTTP server 复验(不是 fetch stub):首个请求直接断连、第二个正常响应 → 2 次请求后成功(约 444 ms);始终断连 → 3 次请求后得上面的诊断串;100 ms 时取消 → 只发 1 次请求、102 ms 返回 WEB_ABORTED。
  • CI 会额外跑 test:engine、test:install、test:pack。

复现边界

本机未能复现报告者的 Windows + 6 子代理并发条件;上述诊断基于其 issue 证据、undici 源码行为与本机网络实测。重试用例和真实断连复验均在本机完成,未使用真实 Command Code 账号做并发搜索。

报告者在 0.16.3 上 10 分钟内 14 次 web_search 失败 3 次,4-query 的 2 次调用
全部失败,单 query 12 次失败 1 次;失败发生在拿到 HTTP 状态之前,面板记录
21.738 s、0 token、$0.0000,同一 query 立即重试全部成功。

- undici 的 fetch 把连接/读取阶段失败统一包成 TypeError: fetch failed,真实
  原因码在 error.cause;本机对不可达地址实测 10.5 s 后为
  UND_ERR_CONNECT_TIMEOUT,双地址族各等一轮可到约 20 s,与报告时长同量级。
- 搜索此前只发一次 fetch、失败即抛;DSH 的 dsh-tool-web 又在多 query 时第一个
  失败就 abort 兄弟并整体抛出,于是一次抖动丢掉整批结果。
- 现在仅对「未收到 HTTP 响应」重试,最多 3 次尝试(2 次重试),退避 500 ms→1 s
  且 ±20% 抖动;账号只在进入重试前解析一次;等待监听调用方信号,取消或工具
  协程超时仍为 WEB_ABORTED。已收到 HTTP 状态(含 401/403/429)与响应体解析
  失败不重试——那些是结论而非抖动。
- 最终错误追加 cause 原因码、目标 origin(httpOrigin 丢弃路径与查询)、耗时与
  尝试次数,复用 transportFailureReason(),`fetch failed` 不再同时承担 DNS、
  代理、超时三种解释。
- 多 query 的部分成功仍属 DSH 的 dsh-tool-web 策略,未在本插件改动。

验证:typecheck 退出码 0;npm test 1161/1161(新增 5 条:重试成功且密钥只
解析一次、3 次后停止并在错误串带原因/目标/次数、403 不重试、退避等待可被
取消打断、退避基数与抖动边界);build 两段 Build complete。另用真实 HTTP
server 复验:首个请求断连后第 2 次请求成功(约 444 ms);一直断连时 3 次请求
后错误串为「连接中断(UND_ERR_SOCKET);目标 http://127.0.0.1:…;耗时
1693ms;已尝试 3 次」;100 ms 时取消则只发 1 次请求、102 ms 返回 WEB_ABORTED。

Co-authored-by: Mars-Sea <33591741+Mars-Sea@users.noreply.github.com>
@Mars-Sea

Mars-Sea commented Nov 3, 2026

Copy link
Copy Markdown
Owner

独立审查评论(不阻塞合并)

结论:没有阻塞问题,可以合并。CI 全绿;我在隔离副本里独立复跑了测试与构建,并用真实 HTTP server(不是 fetch stub)验证了三条路径。以下按优先级列出建议,其中 P2 主要是文档/取舍问题,P3 都是可选优化。

已独立核验

  • npm run typecheck 退出码 0;tests/web-search.test.ts 33/33;npm test 1161/1161。
  • npm run build 后 lib/index.js、lib/index.d.ts、lib/client.js 与提交内容逐字节一致(产物不是旧的)。
  • 真实 HTTP server:
    • 首个连接断连、第二个正常响应 → 2 次请求后成功,约 534 ms;
    • 始终断连 → 3 次请求后报 Command Code web search request failed: fetch failed(连接中断(UND_ERR_SOCKET);目标 http://127.0.0.1:…;耗时 1631ms;已尝试 3 次);
    • 120 ms 时取消 → 只发 1 次请求、122 ms 返回 WEB_ABORTED。
  • 错误串只带 origin,路径/查询(以及反代前缀)不会回显;账号只解析一次;收到 HTTP 状态与响应体失败不重试,均与描述一致。

P2:重试预算与 dsh-tool-web 的 30 s 合作预算(建议补文档)

现状:dsh-tool-web 的 searchTimeoutMs 默认 30000 ms,由 dsh-tool-call-timeout-policy 以 timeoutMs 强制,并把 exec.signal 转发给每个 search()。undici 连接超时默认约 10 s(PR 描述实测 10.5 s)。三次尝试加退避约为 31.5–33 s,已超过 30 s 预算:如果每次都在连接阶段超时,工具 deadline 会在第三次尝试期间触发,provider 以 WEB_ABORTED 结束,新的「原因 + 目标 + 耗时 + 次数」诊断串不会出现。

报告者的那次首败是 21.7 s,之后只剩约 8.3 s;扣掉 0.5 s 退避,第二次尝试必须在约 7.8 s 内成功(他手动重试成功了,所以修复大概率有效,但余量很小)。

PR 正文「未改动 / 边界」已经写明「若工具协程超时恰好落在重试期间,最终仍按取消(WEB_ABORTED)上报」,但这层算术与「诊断串可能被取消替代」没有落到 docs/agent-reference/accounts-and-retry.md / docs/决策记录.md 的持久记录里。

建议(二选一即可,不一定要改代码):

  1. 在文档里补一句:3 次尝试最坏约 31.5–33 s,超过默认 30 s 工具预算;慢失败场景下最终可能是取消,而不是诊断串。
  2. 或者按「已耗时」收敛重试(例如首败已超过某个阈值就只再试一次 / 不再试),并同步文档;代价是报告者 21.7 s 的慢失败场景可能不再重试。

P3:其他小建议

  1. 重试判定范围:现在所有非取消的 fetch 拒绝都会重试,包括 TLS 证书错误、UND_ERR_INVALID_ARG 等确定性失败,各多等最多约 1.5 s 才抛出同样的错误。可考虑复用 transportFailureReason() 的码表做一次「瞬时类」判断,或至少在注释里说明「所有未收到响应的失败都按瞬时处理」。
  2. 文档措辞:「every response-body failure are NEVER retried: those are answers」不准确——响应头已到但 body 被截断属于传输失败,不是答案。不重试的真实理由是请求已被服务端处理,重放有计费/重复执行代价。建议改一句措辞(代码行为本身不用改)。
  3. 测试稳定性:an abort during the retry wait stops it instead of sleeping through 用真实 500 ms 定时器并断言 < 450 ms,机器负载高时有抖动风险(当前 CI 已通过)。既然已有 retryWait 注入点,可以注入一个监听 signal 的假等待或假定时器,让断言更稳。
  4. 测试覆盖:没有断言传给 retryWait 的延迟序列是 [500, 1000](目前只测了 searchRetryDelayMs() 的边界)。可选补一条捕获调用参数的用例。

未覆盖

  • 未在报告者的 Windows + 6 并发条件下复现;test:engine / test:install / test:pack 未本地跑,由 CI 覆盖。

@ben-bot-ai

Copy link
Copy Markdown
Collaborator Author

收到审查意见:会补上重试预算与取消的持久文档、修正“响应体失败”措辞、把取消测试改成确定性,并保持重试判定范围不变、注明理由。

- accounts-and-retry / 决策记录:补充 3 次连接超时尝试加两次退避约 31.5–33 s,
  超过 dsh-tool-web 默认 30 s 预算;慢失败时 deadline 落在重试期间,最终是
  WEB_ABORTED、诊断串不会出现(报告者 21.7 s 首败只剩约 8 s)。说明未加
  per-attempt 超时或按 elapsed 收敛的理由。
- 修正「响应体失败不重试」的理由:响应头已到、请求已被接受并可能执行,重放有
  重复计费代价,而不是「响应体失败是答案」。
- 注明所有非取消的 fetch 拒绝都按瞬时处理,以及最多约 1.5 s 的有界代价;不引入
  可能遗漏未收录瞬时码的码表 allowlist。
- 测试:导出 searchRetryWait,取消用例注入生产等待并等其就绪,去掉真实 500 ms
  定时器与耗时断言;新增传给等待函数的 500 ms→1 s 延迟序列用例。
- 重建 lib/。

验证:typecheck 退出码 0;npm test 1162/1162(tests/web-search.test.ts 34/34);
build 两段 Build complete;git diff --check 干净。

Co-authored-by: Mars-Sea <33591741+Mars-Sea@users.noreply.github.com>
@ben-bot-ai
ben-bot-ai force-pushed the agent/investigate-web-search-failures-3cdfd1f3408a branch from dd94e7f to 45fb1b5 Compare November 3, 2026 09:54
@Mars-Sea

Mars-Sea commented Nov 3, 2026

Copy link
Copy Markdown
Owner

复核结果:上一轮问题已解决;剩余几处瑕疵请处理

在 head 45fb1b5 上独立复核通过:

  • npm run typecheck 退出码 0;tests/web-search.test.ts 34/34;npm test 1162/1162。
  • npm run build 两段 Build complete;重建后的 lib/index.js、lib/index.d.ts、lib/client.js 与提交逐字节一致。
  • 真实 HTTP server 复验:首个连接断连、第二个正常响应 → 2 次请求后成功(约 569 ms);始终断连 → 3 次请求后得到 ...(连接中断(UND_ERR_SOCKET);目标 http://127.0.0.1:…;耗时 1256ms;已尝试 3 次);取消 → 只发 1 次请求、123 ms 返回 WEB_ABORTED。
  • 上一轮 5 点(重试预算边界文档、重试范围理由、响应体措辞、取消测试确定性、延迟序列断言)确认均已处理。

请修改

  1. lib/index.d.ts 的 JSDoc 链接失效。 src/web-search.ts 中 CommandCodeSearchProviderDeps.retryWait 的注释写的是 defaults to {@link searchRetryWait},但 searchRetryWait 只从 src/web-search.ts 导出,没有出现在公开的 lib/index.d.ts 里(该文件全文只有这一处提到它)。类型消费者看到的是一条无法解析的链接。建议把源码注释改成不依赖 @link 的表述(例如 “defaults to the module's retry wait”),再重建 lib/。同类问题:lib/index.js 模块注释里的 {@link SEARCH_MAX_ATTEMPTS} 在产物里也找不到该符号(常量被内联为 3);如果在意生成产物的文档链接,可一并改成纯文本。
  2. 措辞不准确。 searchRetryWait 的注释说 “without depending on a real 500 ms timer”,但测试实际上仍通过生产等待函数创建了 500 ms 定时器(只是取消后立即 clear、不等待它触发)。建议改成 “without waiting out a real 500 ms timer” 或 “without racing a real timer”。

建议(可选)

  1. 默认接线覆盖回归。 取消用例注入 retryWait 后,this.deps.retryWait ?? searchRetryWait 的默认接线不再有任何用例覆盖(改动前由取消用例覆盖)。可以补一条不注入 retryWait 的用例,用 fake timers 或微任务控制时序,断言默认等待可被取消;否则默认值改错不会被测试发现。
  2. 文档冗余。 31.5–33 s / 30 s 预算这段现在同时出现在 CHANGELOG.md、docs/决策记录.md、docs/agent-reference/accounts-and-retry.md、PR 描述和源码注释五处。按仓库「文档精炼」口径,建议 agent reference + 决策记录保留完整算术,CHANGELOG 与源码注释压成一句结论或指针。

另:force-push 重写了历史(原 dd94e7f 已不存在),仅作提醒,不需要处理。

- `retryWait` 注释不再 `{@link}` 未导出到 lib/index.d.ts 的 searchRetryWait;
  模块注释中的 SEARCH_MAX_ATTEMPTS 与 transportFailureMessage 注释中的
  httpOrigin 改为纯文本,避免产物里的链接无法解析。
- searchRetryWait 说明改为「without waiting out a real 500 ms timer」:测试仍会
  创建生产定时器,只是取消后立即 clear、不等待它触发。
- 新增不注入 retryWait 的取消用例(fake timers + 捕获的真实 guard 定时器),
  覆盖 `retryWait ?? searchRetryWait` 默认接线;已用变异验证(移除 abort 监听
  时该用例失败)。
- 按文档精炼口径收敛重复算术:完整预算边界保留在
  docs/agent-reference/accounts-and-retry.md 与 docs/决策记录.md,CHANGELOG
  和源码注释压成结论 + 指针。
- 重建 lib/。

验证:typecheck 退出码 0;npm test 1163/1163(tests/web-search.test.ts 35/35);
build 两段 Build complete;git diff --check 干净。

Co-authored-by: Mars-Sea <33591741+Mars-Sea@users.noreply.github.com>
@Mars-Sea
Mars-Sea merged commit ed60ae5 into main Nov 3, 2026
5 checks passed
C715543139 added a commit to C715543139/dsh-commandcode-provider that referenced this pull request Nov 4, 2026
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