Skip to content

fix: 为 LLM Provider 增加超时与重试预算边界 - #162

Merged
rojim666 merged 1 commit into
rojim666:mainfrom
z2Ace0107:codex/issue-69-pr03-provider-timeout-retry
Sep 13, 2026
Merged

rojim666 merged 1 commit into
rojim666:mainfrom
z2Ace0107:codex/issue-69-pr03-provider-timeout-retry

Conversation

@z2Ace0107

@z2Ace0107 z2Ace0107 commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

关联 #69。

Agent Loop 传入运行时剩余预算后,Provider 仍可能让单次 SDK 请求使用过长 timeout,或让 SDK 内部 retry 与 Provider 自身 retry 叠加,导致请求超出本轮运行预算。

本 PR 仅处理 Anthropic/OpenAI-compatible Provider 的超时与重试边界,不声称完整解决 #69。

Behavior

  • Anthropic 和 OpenAI Provider 的单次请求 timeout 限制为 min(Provider 默认 timeout, remaining_s)。
  • 对带运行时预算的请求关闭 SDK 内部 retry,由 Provider 统一管理网络重试。
  • 当下一次 retry 的固定 backoff 无法放入剩余预算时,不再等待或发起下一次请求。
  • 零值和负值预算直接拒绝请求,不调用 SDK。
  • asyncio.CancelledError 正常向上层传播,不被误判为可重试网络错误。
  • remaining_s=None 继续保持原有 Provider 默认 timeout/retry 行为。
  • 为 Anthropic/OpenAI 两个 Provider 补充上述预算、超时、重试、限流和取消边界测试。

Validation

运行环境:uv 锁定依赖环境,Python 3.12.13。

专项测试:

uv run --locked --group dev python -m pytest tests/unit/test_llm_provider.py tests/unit/test_openai_provider.py -q -k "remaining_budget or retry"
15 passed, 45 deselected

完整两个 Provider 单测:

uv run --locked --group dev python -m pytest tests/unit/test_llm_provider.py tests/unit/test_openai_provider.py -q
25 failed, 35 passed

上述 25 个失败均为同一个主线基线问题:

ModuleNotFoundError: No module named 'sztu_code.core.prompts.Sztubuddy'

调用链在进入实际 Provider 请求逻辑前即失败:

Provider.chat()
→ compact.context_usage
→ compact.__init__
→ compactor.py
→ prompts.__init__
→ system_prompt.py
→ import Sztubuddy

当前 Python prompt 资源实际仍为 workbuddy.py 和 workbuddy/,而 system_prompt.py 导入 Sztubuddy。该命名/目录大小写不一致属于主线已有问题,本 PR 未混入该独立修复。

仅在测试进程中临时将 workbuddy.py/workbuddy/ 映射为 Sztubuddy 后,两个 Provider 的 60 个测试全部通过;该映射仅用于诊断,不是本 PR 的正式代码修复。

其他检查:

uv run --locked --group dev ruff check src/sztu_code/core/llm/provider.py src/sztu_code/core/llm/openai_provider.py tests/unit/test_llm_provider.py tests/unit/test_openai_provider.py
通过

uv run --locked --group dev mypy src/sztu_code/core/llm/provider.py src/sztu_code/core/llm/openai_provider.py
Success: no issues found in 2 source files

uv run --locked --group dev python -m compileall -q src
通过

git -c core.whitespace=cr-at-eol diff --check
通过

CI 基线说明

本 PR 的两个 GitHub CI 工作流均已完成,但均以失败结束:

作为对照,同一仓库 main 基线提交 2e9a3136081c2e36a380e761c1bfbbeeba4a6532 的对应运行也失败:

因此当前没有证据表明 PR-03 引入了这些 CI 失败。PR-03 的 merge-base diff 只有以下 4 个文件,未涉及 prompt、Office 解析、TypeScript 资源模块、图片工具或 Linux 打包:

  • py-runtime/src/sztu_code/core/llm/provider.py
  • py-runtime/src/sztu_code/core/llm/openai_provider.py
  • py-runtime/tests/unit/test_llm_provider.py
  • py-runtime/tests/unit/test_openai_provider.py

失败定位:

  • parse_document/Office 测试:read_file 的 Python office helper 导入 sztu_code.core.prompts.Sztubuddy,但基线实际仍是 workbuddy.py 和 workbuddy/;本地最小复现同样失败。
  • npm daemon 测试:bundle banner 与 image-utils.ts 重复声明 createRequire,导致 daemon 启动后 ECONNREFUSED。
  • 图片工具测试:测试把 Buffer.from("image-bytes") 当作 PNG,sharp 正确拒绝了无效图片。
  • 图片消息测试:旧断言没有同步实际返回的 source.type: "base64"。
  • workbuddy.test.ts:测试导入不存在的 Sztubuddy-resources.js。
  • Linux Desktop:bundle 包含 canvas-linux-x64-musl 原生模块,linuxdeploy 对其执行 ldd 时退出码为 1。

以上是主线基线问题,CI 修复应另行诊断和修复,不与本 PR 混合。

Risk

  • 本 PR 只改变带 remaining_s 请求的 Provider timeout/retry 边界;没有运行时预算时保留原有行为。
  • 不涉及协议、权限、工具、Subagent、Context compaction、cleanup、Trace、TUI 或桌面端。
  • 完整 CI 可能继续报告上述主线 Sztubuddy 导入失败;该问题应通过独立诊断和修复 PR 处理,不与本 PR 混合。
  • 工具调用 timeout/取消清理、权限等待、Subagent deadline 传播、Context compaction、cleanup、Trace 和完整生命周期测试留给后续 PR。

UI evidence

N/A:无 TUI 或桌面端视觉变化。

Checklist

  • 变更范围与关联 Issue 一致,没有混入无关重构。
  • 没有提交凭据、日志、缓存、私有源码或本地评测产物。
  • 已添加与风险匹配的测试。
  • 无协议变化,无需重新生成 docs/reference/wire-protocol.md。
  • 无用户文档、配置示例或迁移变化。
  • 提交包含 Signed-off-by(DCO)。

Refs #69

@rojim666
rojim666 marked this pull request as ready for review September 13, 2026 10:00
@rojim666
rojim666 self-requested a review as a code owner September 13, 2026 10:00
Copilot AI lite review requested due to automatic review settings September 13, 2026 10:00
@rojim666
rojim666 merged commit 3381784 into rojim666:main Sep 13, 2026
0 of 2 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Recheck deadlines before SDK calls and handle SDK-level connection exceptions in both providers' retry paths.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds runtime-budget-aware timeout and retry boundaries for Anthropic and OpenAI-compatible providers.

Changes:

  • Caps request timeouts by remaining budget.
  • Coordinates provider retries and backoff with the remaining budget.
  • Adds coverage for budget, retry, rate-limit, and cancellation behavior.
File summaries
File Description
py-runtime/tests/unit/test_openai_provider.py OpenAI budget and retry tests
py-runtime/tests/unit/test_llm_provider.py Anthropic budget and retry tests
py-runtime/src/sztu_code/core/llm/provider.py Anthropic timeout and retry handling
py-runtime/src/sztu_code/core/llm/openai_provider.py OpenAI timeout and retry handling
Review details

Suppressed comments (2)

py-runtime/src/sztu_code/core/llm/openai_provider.py:659

  • The exhaustion check runs before _stream_once builds the request, but with a very small positive budget this timeout calculation can occur after the deadline; it clamps to 0.0 and the subsequent create call is still invoked. Re-check the deadline immediately before the SDK call and raise the budget timeout when it has expired, so direct budgeted calls cannot start a request after their deadline.
            timeout_s=_request_timeout(self._timeout_s, deadline),

py-runtime/src/sztu_code/core/llm/provider.py:257

  • The exhaustion check runs before request setup, but this _request_timeout calculation can happen after a very small positive budget has elapsed; it clamps the timeout to 0.0 and the code still proceeds to invoke messages.stream. Re-check the deadline immediately before the SDK call and raise the budget timeout when it has expired, so direct budgeted calls cannot start a request after their deadline.
            request_timeout = _request_timeout(self._timeout_s, deadline)
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +663 to +666
# The provider owns the outer retry policy. Disable SDK-level
# retries for budgeted calls so one attempt cannot consume the
# same remaining budget multiple times internally.
request_client = self._client.with_options(max_retries=0)
Comment on lines +260 to +264
# The provider owns the outer retry policy. Disable SDK-level
# retries for budgeted calls so one attempt cannot consume the
# same remaining budget multiple times internally.
request_client = self._client.with_options(max_retries=0)
request_kwargs["timeout"] = request_timeout
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.

3 participants