☰
Sandcastle 的 Provider 错误快速失败设计:为什么对限流、鉴权失败与配额错误一律不做重试
2026/9/26 2:10:56 网站建设 项目流程

【免费下载链接】sandcastle

Orchestrate sandboxed coding agents in TypeScript with sandcastle.run()

项目地址:https://gitcode.com/gh_mirrors/sandcastl/sandcastle
点击查看免费下载

导读

本文解析 Sandcastle 的一项关键架构决策:对 Agent Provider(Claude Code、Codex、Pi、OpenCode 等)上报的限流(rate limit)、鉴权失败(auth failure)、配额超限(quota error)、网络超时等错误,Sandcastle 一律不做重试,而是快速失败(fail fast)并立即向用户呈现可操作的错误信息。该决策记录于仓库的 .out-of-scope/provider-error-retry.md,属于官方明确划定的"不在范围内"的设计裁定。读完本文,你将理解这一决策背后的四条理由、Sandcastle 在源码层面如何把"不重试"落到实处(非零退出码 →AgentError→ 格式化输出 →exit(1)),以及 provider 错误信息如何被完整地传递给用户。

决策本身:不重试,就是设计

Sandcastle 对 provider 错误的处理有一条明确且简单的规则:不重试。无论错误来自限流、鉴权失败、配额超限还是网络超时,Sandcastle 都不会自动重跑一次 agent 调用。这条规则以"决策记录"的形式固化在仓库中:

Decision:Sandcastle does not retry on provider errors (rate limits, auth failures, quota errors, network timeouts, etc.).

需要注意的是,这个决策文档位于仓库的.out-of-scope/目录——它明确表示"provider 错误重试"这一能力不在 Sandcastle 的职责范围内,未来也不计划内置。这本身就是对边界的一次清晰界定:哪些事该由 Sandcastle 做,哪些事该由上层(provider / harness 层)做。

为什么不做重试:四条理由逐条拆解

原文档给出了四条相互支撑的理由,每一条都指向"重试是一个错误的抽象层级"这个结论。

理由一:Sandcastle 不拥有 API 连接与错误接口

Sandcastle 与 agent 的交互方式是通过 shell 调用 provider 的命令行工具(例如 Claude Code 的claude -p、Codex 的codex exec、Pi 的pi -p --mode json)。从 src/AgentProvider.ts 的AgentProvider接口可以看到,每个 provider 的核心就是buildPrintCommand(options)返回一条要执行的 shell 命令,以及parseStreamLine(line)解析其 stdout 流。Sandcastle 拿到的只是"命令的退出码 + stdout + stderr",它并不直接持有与模型 API 之间的连接,也接触不到 API 原始的错误响应结构。

因此,"限流该等多久再重试""鉴权失败是不是永久性的"这类判断,Sandcastle 无从准确作答——它缺少判定所需的上下文。

理由二:解析 provider 专属错误形状 = 为一个不受控的接口负责

要判断"这个错误是否可重试",就必须解析每个 provider 各自的错误格式。比如 Pi 会在 stdout 上输出agent_error/error事件,Codex 会输出error事件,OpenCode 同样在 stdout 输出error事件(见 src/AgentProvider.ts 中parsePiStreamLine、parseCodexStreamLine、parseOpenCodeStreamLine的注释)。这些格式由各家 CLI 自行定义、随时可能改变:

  • Sandcastle 无法控制这些接口的演进;
  • 一旦某个 provider 调整了错误事件的结构,Sandcastle 的重试判定逻辑就会静默失效——要么漏判该重试的,要么误判不该重试的;
  • 为每个 provider 维护一份"可重试错误特征库",等于把不可控的外部接口变成了自己的长期维护负担。

原文档的表述非常直接:"Parsing provider-specific error shapes to detect retryable conditions means taking responsibility for an interface we don't control and that could change at any time."(解析 provider 专属错误形态以检测可重试条件,意味着为一个我们无法控制且随时可能变化的接口承担责任。)

理由三:盲目重试会掩盖真实错误并浪费资源

如果 Sandcastle 简单地"任何非零退出码就重试一次",会出现两类严重问题:

  • 掩盖真实错误:坏提示词(prompt 设计错误)、鉴权失败(密钥过期)、配置问题(模型名拼错、参数不合法)这些错误并不会因为重试而消失。盲目重试会让用户看到"又失败了一次"的重复噪音,而不是一次性暴露根因;
  • 浪费时间和金钱:agent 调用按 token 计费,一次重试就是一次新的完整调用开销。对配额类错误重试,还可能加重上游压力,让限流更严重。

所以"盲目重试任何非零退出码"被明确否决:非零退出码只意味着"这次调用失败了",不代表"重试能成功"。

理由四:快速失败给用户即时、可行动的反愤

不重试的正面价值是反馈速度。失败立即返回,用户能立刻看到错误并做出选择:

  • 升级计划:配额不足时换更高级别的模型或提高预算;
  • 等待:限流可能稍纵即逝,用户自己决定何时再跑;
  • 切换 provider:当前 provider 不可用,改用别的 agent。

这四种行动都要求"错误尽快呈现在用户面前",而这恰恰是"快速失败"提供的。

原则:错误处理属于 provider/harness 层

原文档最后给出了贯穿全文的原则:

Principle:Error handling and retry logic belong in the provider/harness layer, not in Sandcastle. Sandcastle fails fast on provider errors.

即:重试逻辑的归属是 provider 或 harness 层。如果用户确实需要自动重试,正确的做法是在调用 Sandcastle 的外层(比如自己的 CI 脚本、工作流编排器)基于退出码自行实现带退避(backoff)的重试策略;Sandcastle 自身保持单一职责——执行一次、失败就报错。原文档还记录了这条决策的出处:"Rejected in: #246",即 #246 中曾提出过在 Sandcastle 内做 provider 错误重试的方案,最终被否决。

源码层面:Sandcastle 如何把"不重试"落到实处

快速失败在实现上是一条清晰的调用链:非零退出码 → 构造AgentError→ 格式化错误信息 → 进程以退出码 1 结束。

第一步:非零退出码被转换为AgentError

在 src/Orchestrator.ts 中,每次迭代执行完 provider 命令后,如果execResult.exitCode !== 0,编排器立即构造一个AgentError并Effect.fail:

if (execResult.exitCode !== 0) { // Prefer stderr; fall back to resultText (from parsed stream events), // then to the tail of raw stdout (last 20 non-empty lines). let errorDetail = execResult.stderr; if (!errorDetail.trim()) { errorDetail = resultText; } if (!errorDetail.trim()) { const lines = execResult.stdout.split("\n").filter((l) => l.trim()); errorDetail = lines.slice(-20).join("\n"); } return yield* Effect.fail( new AgentError({ message: `${provider.name} exited with code ${execResult.exitCode}:\n${errorDetail}`, }), ); }

注意这里没有出现任何"重试""重试次数""退避"的逻辑——失败就是失败,直接向上抛。AgentError的定义在 src/errors.ts,并带有一个可选的preservedWorktreePath字段(失败时若保留了 worktree,会把宿主机路径一并带给用户)。

第二步:错误被格式化并直接退出进程

AgentError最终会经由 src/ErrorHandler.ts 的withFriendlyErrors处理:它以Effect.catchTags捕获包括AgentError在内的全部SandboxError标签,调用showErrorAndExit——先用Display服务以 error 级别打印格式化后的消息,然后process.exit(1)(src/ErrorHandler.ts):

const showErrorAndExit = (error: SandboxError) => Effect.gen(function* () { const d = yield* Display; yield* d.status(formatErrorMessage(error), "error"); return yield* Effect.sync(() => process.exit(1) as never); });

formatErrorMessage对AgentError的输出是Agent invocation failed: ${error.message}(src/ErrorHandler.ts)。整个流程没有任何循环重试的路径——一条错误路径,一路到底,退出码 1。

第三步:错误信息如何完整抵达用户

为了让"快速失败"仍然"信息完整",Sandcastle 在收集错误详情时有三层回退(fallback),优先级为:

  1. stderr:provider 命令写到 stderr 的内容优先保留;
  2. resultText:由parseStreamLine解析出的result事件文本;
  3. stdout 尾部:原始 stdout 的最后 20 个非空行。

这个回退链在 src/Orchestrator.ts 中实现。而第三条回退的铺垫工作,早在 provider 解析层就完成了:Pi、Codex、OpenCode 都会把输出在 stdout 上的鉴权失败、限流、API 错误事件转换为result事件(见 src/AgentProvider.ts、src/AgentProvider.ts、src/AgentProvider.ts 的注释),这样当 stderr 为空时,Orchestrator 的 stderr-empty fallback 依然能把"Rate limit exceeded"这类真实原因呈现给用户,而不是只丢一个冷冰冰的退出码。

测试如何验证"不重试且不丢错误"

仓库的测试直接印证了这套行为。在 src/Orchestrator.test.ts 中有一个典型用例:agent 以非零退出码结束、stderr 为空,但结构化解析器把 stdout 上的result事件解析为"Rate limit exceeded, please retry later"——测试断言:

  • 抛出的错误是AgentError实例;
  • 错误消息包含"Rate limit exceeded, please retry later";
  • 换言之:Sandcastle 既不重试,也绝不吞掉这条错误,而是原样交给用户。

另一个用例(src/Orchestrator.test.ts)则验证 stderr 非空时优先保留 stderr,不回退到 stdout——避免把无关输出混入错误信息。

边界澄清:哪些"失败"同样不重试

"provider 错误不重试"并不是唯一的快速失败路径。从 src/errors.ts 定义的错误家族看,Sandcastle 的整套错误处理都是单次失败语义:

  • AgentIdleTimeoutError:agent 超过空闲超时(默认 600 秒)无输出即失败(src/Orchestrator.ts);
  • CompletionTimeoutError家族:SyncInTimeoutError、HookTimeoutError、PromptExpansionTimeoutError、MergeToHostTimeoutError等,任一环节超时都直接失败;
  • 基础设施类:DockerError、PodmanError、WorktreeError、SyncError等同样一次失败即报错(src/errors.ts)。

这些错误统一汇入SandboxError联合类型(src/errors.ts),由withFriendlyErrors统一格式化后退出。也就是说,"错误即终止、交由用户决策"是 Sandcastle 全链路的一致哲学,provider 错误只是其中最典型的场景。

对 provider 开发者与集成者的启示

这套决策对两类人都有直接指导意义:

若你在为 Sandcastle 新增 agent provider,请阅读 docs/agents/adding-an-agent-provider.md。它明确要求 provider 的 stdout 流必须能够被解析出:助手文本、工具调用、最终结果、错误事件和session ID。其中"错误"一项特别指出:需要确认 CLI 的错误是输出在 stdout 还是 stderr——Codex 和 Pi 将鉴权/限流错误作为 JSON 事件输出在 stdout,Sandcastle 会把这些捕获为result事件以便呈现给用户。实现时遵循该文档的"Patterns to follow":shell 转义所有插值、优先用 stdin 传提示词、防御式 JSON 解析、CLI 在 stdout 输出错误时将其转成result事件(供 Orchestrator 的 stderr-empty fallback 使用)。

若你在 Sandcastle 之上构建工作流,请把自动重试放在你自己的编排层:监听进程退出码,对可重试的错误类型自行实现带指数退避(exponential backoff)和抖动(jitter)的重试,并设置重试上限——这正是原文档所说"错误处理和重试逻辑属于 provider/harness 层"的落点。

小结

Sandcastle 对 provider 错误"一律不重试、一律快速失败"并非能力缺失,而是经过论证的架构边界:它通过 shell 调用外部 CLI,不拥有 API 连接与错误接口,因而拒绝为不可控的 provider 错误格式承担维护责任;盲目重试会掩盖坏提示词、鉴权失败等真实错误并浪费 token;快速失败则让用户立即得到可行动的反馈(升级计划、等待、切换 provider)。这条决策在实现上体现为 src/Orchestrator.ts 中非零退出码 →AgentError→ src/ErrorHandler.ts 格式化输出 →exit(1)的单一失败路径,并由 src/Orchestrator.test.ts 验证了"不重试、不丢错误"的行为。需要重试的集成方,应当在 Sandcastle 之上的 harness 层自行实现。

【免费下载链接】sandcastle

Orchestrate sandboxed coding agents in TypeScript with sandcastle.run()

项目地址:https://gitcode.com/gh_mirrors/sandcastl/sandcastle
点击查看免费下载

相关推荐

上一篇:NetworkNightmare安全警示:合法使用渗透测试工具的终极指南
下一篇:Cursor插件文档编写终极指南:如何创建专业README.md与API参考的最佳实践

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询