# 密钥代理错误必须凭证据重试

密钥代理必须报告记录能够证明的事实，而不是异常信息暗示的情况。如果代理无法证明远程操作根本没有尝试，就不能把这次调用说成预检失败。一旦请求字节或 SSH exec 请求可能已经到达另一端，即使本地错误显示“超时”或“连接重置”，结果也可能处于未知状态。

这一区别决定了代理是否会重复付款、二次轮换同一凭据、再次部署同一版本，或者安全地重试一项从未离开本机的工作。因此，一套有用的错误约定需要承载两个彼此独立的事实：代理在哪个阶段停止，以及它对远程执行结果知道多少。单个“暂时性”字段无法同时表达这两件事。

下面的设计同时适用于 HTTP 调用和 SSH 命令。它还假定代理拥有持久化的操作记录。只存在于内存中的状态可以改善错误信息，但在代理或调用方重启后，它无法成为允许重试的依据。

## 原因不等于结果

每次失败都需要包含阶段、结果和重试处理方式。阶段告诉运维人员该从哪里排查。结果告诉调用方，远程一侧是否可能已经改变状态。重试处理方式则根据已经记录的证据和操作语义，告诉自动化程序此刻可以做什么。

对外公开五个阶段：

- `validation`：代理在选择或使用密钥之前拒绝了操作。
- `credential_injection`：代理无法取得凭据、获得授权或附加凭据。
- `connection_setup`：名称解析、路由、TCP、TLS、SSH 传输、主机验证或远程认证在分派前失败。
- `remote_execution`：代理已经分派操作，正在等待或已经收到远程结果。
- `result_delivery`：代理已经记录远程结果，但无法把完整结果返回给调用方。

这些阶段用于诊断，不是重试策略。一次 `connection_setup` 错误可以证明应用请求从未发出，而复用连接突然中断时，代理可能无法确定对端是否收到了写入内容。一次 `result_delivery` 错误也可能对应一个已经确定的远程成功结果。把这两种情况都归为普通网络错误，会丢掉调用方最需要的事实。

使用四种结果状态：

- `not_attempted`：持久化证据表明远程操作没有越过分派边界。
- `rejected`：远程系统返回了完整且可信的拒绝结果，并且没有表示成功。
- `committed`：代理已经取得该操作完整且可信的结果。
- `unknown`：操作可能已经分派，但代理没有拿到足以确定结果的完整响应。

`committed` 不等于成功。命令以状态码 23 退出，或者 HTTP 请求收到完整的 500 响应，都有确定的结果。远程应用至少运行到了能够给出答复的阶段。把这种情况称为“执行失败”会诱发重复操作。

重试处理方式也应同样明确：`never`、`after_correction`、`backoff`、`same_idempotency_key`、`reconcile` 或 `fetch_result`。代理根据证据、方法语义、远程保证和结果日志状态计算它。调用方绝不能从错误字符串里自行推断。

不要在公开约定中把 `rejected` 和 `committed` 合并成一个 `known` 值。被拒绝的请求也许可以修正后再提交，而已提交的结果要求调用方使用或核对该结果。两者都确定此次尝试发生过，但后续流程不同。

异步接收还需要多一项证据，而不需要增加新的结果枚举。一个完整的 HTTP 202 响应，是这次提交操作的确定结果，但它不能证明排队任务已经完成。记录服务返回的任务标识符和状态资源，然后把任务本身作为另一项操作跟踪。因为任务仍在等待就重试提交，可能会把它排入队列两次。

这一区分厘清了许多 SDK 经常混淆的概念。错误原因回答“本地哪里出了问题？”，远程结果回答“远端可能已经发生了什么？”。只读取原因的重试引擎并不安全。

## 验证和注入必须在分派前失败

只有当代理尚未打开能够承载操作的通道时，验证错误才是真正的预检失败。在解析主机或访问密钥之前，代理应拒绝格式错误的目标、不支持的方法、缺失的操作字段、过大的负载、未知的凭据引用和禁止覆盖的请求头。记录 `phase=validation`、`outcome=not_attempted`，重试通常设为 `retry=after_correction`。

自动重试无法修复确定性的验证失败。不断重复同一个无效负载只会浪费容量，还可能掩盖代理程序的循环。返回稳定的代码，例如 `INVALID_TARGET`、`UNSUPPORTED_ACTION` 或 `PAYLOAD_LIMIT`，并附上调用方可以修正的字段路径。如果被拒绝的密钥引用含有敏感命名信息，不要把它原样返回。

如果代理在发出任何远程请求字节前失败，凭据注入仍属于预检。密钥库锁定、批准被拒、凭据不存在、不支持的注入方式和本地密钥解密失败都属于这一阶段。结果仍是 `not_attempted`，但重试建议各不相同。锁定的密钥库可以在用户操作后重试；一次审批拒绝通常应对该调用设为 `never`；缺失密钥则需要修正配置。

错误和操作记录中都不能包含凭据材料。可以记录凭据标识符或非敏感的版本标签、注入方式以及阻止调用的决定。如果为了证明注入发生过而记录完整的 `Authorization` 请求头，代理本身就失去了意义。

这里有一条细微的分界线。如果代理在私有缓冲区中构造了带凭据的完整 HTTP 请求，但在写入前失败，远程操作仍未尝试。如果代理把缓冲区交给传输 API，而 API 返回部分写入或含义不明的写入结果，那么注入已经成功，分派也可能已经开始。应按最后一个有证据证明的边界分类，而不是按捕获异常的函数栈位置分类。

预检还需要配置快照。如果验证读取了一份路由定义，分派时又读取了修改后的定义，已有证据就不再描述实际执行的操作。在授权前，把规范化目标、凭据版本、允许的注入方式和请求指纹绑定到调用。如果任何绑定值发生变化，应创建新调用，而不是修改旧记录。

人工审批期间尤其需要这样做。审批卡片可能停留在屏幕上，此时代理程序或配置重新加载改变了请求正文、主机或选定密钥。代理必须批准即将分派的指纹，并在写入前立即再次校验。指纹不匹配应记为 `validation/not_attempted`，不能拿旧审批去发送新请求。

## 连接设置需要精确的终点

新连接在应用通道建立前失败，通常可以证明 `not_attempted`。DNS 失败、路由失败、TCP 连接被拒、TLS 证书被拒、SSH 主机密钥被拒以及 SSH 用户认证失败，都发生在 HTTP 请求或 SSH 命令可以运行之前。记录已经完成到哪个具体阶段，使调用方无需看到密钥，也能区分主机名错误和凭据被拒。

对使用连接池的 HTTP 客户端来说，“连接失败”这个说法范围太宽。代理取出一条已有连接时，设置阶段早已完成。写入可能因为对端关闭空闲套接字而失败。操作系统可能在部分字节到达对端后才报告管道中断，也可能在对端已经收到整个请求但客户端尚未观察到关闭时报告错误。除非传输层能够提供更强的证明，否则这属于 `remote_execution`，结果是 `outcome=unknown`。

不要把“本地写入调用确认了零字节”当成远端什么都没收到的证明。缓冲式 API 可能先在本地接收数据，之后才失败；一次写入失败也无法说明对端已经读取了什么。真正有用的分派边界，是数据第一次交给可能把应用数据送达远端的传输层。越过这条边界后，默认结果就变成 `unknown`。

HTTP 和 SSH 的连接设置终点也不同。对于 HTTPS，应在分派前完成 DNS、TCP、TLS、证书验证和所有代理隧道。对于 SSH，应完成传输协商、主机验证、用户认证、会话通道创建以及所有必需的环境设置。这些步骤无法证明之后的命令是否启动，但其中任何一步失败，都能证明这条命令从未被请求。

重定向会产生第二条分派边界。完整的 307 或 308 响应已经确定了第一次 HTTP 交换的结果，但跟随重定向会向另一个目标创建新请求。发送前必须重新验证目的地、凭据作用域和方法。绝不能因为客户端库自动跟随重定向，就把授权凭据跨源转发。

HTTP 代理增加了一个观察者，但不会消除不确定性。隧道成功只证明代理打开了一条路径。转发代理可能返回关于自身尝试的完整错误，RFC 9209 也可以说明转发在哪一处失败，但源站结果仍可能未知。记录证据由哪一跳产生，不要把中间节点对自身响应的确定性说成对源站影响的确定性。

当每次尝试都有独立记录，且没有一次越过分派边界时，代理可以在内部重试连接设置。它应限制尝试次数，并把所有尝试写入同一操作记录：

```json
{"attempts":[{"n":1,"stage":"tcp_connect","outcome":"not_attempted","code":"ECONNREFUSED"},{"n":2,"stage":"tls_handshake","outcome":"not_attempted","code":"CERT_EXPIRED"}]}
```

最终错误不能抹掉之前的证据。它应说明两次尝试都没有执行远程操作，并且需要修正配置。

## 分派会改变举证责任

分派时刻必须是明确且持久化的状态转换。在代理第一次把数据交给传输层之前，应向操作记录追加 `dispatch_started`，并按照系统的持久性承诺完成落盘。如果进程在写入后、记录该转换前崩溃，重启后可能会错误地把操作称为未尝试。

严格的预写记录会增加延迟，因此团队常想在发送之后再记录。这个建议受欢迎，是因为正常路径更快，代码也更简单。但对于非幂等操作，它是错误的。少见的崩溃会恰好落入代理必须在丢失工作和重复工作之间选择的缺口。

仅有预写状态并不能证明远端收到了请求。它有意把不确定性放到更安全的一侧。记录 `dispatch_started` 后，结果从 `unknown` 开始。后续可信响应可以把它改成 `rejected` 或 `committed`。代理绝不能把它改回 `not_attempted`。

对于 HTTP，分派开始于第一个请求字节进入连接之前。应跟踪代理是否发送了请求头、是否发完请求正文、是否收到响应头，以及是否收到完整响应正文。这些标记有助于诊断，但 `request_body_sent=true` 仍不能证明应用处理了请求。相反，`request_body_sent=false` 也不能证明应用什么都没做，因为服务器可以在读完整个正文前根据请求头拒绝请求或执行操作。

对于 SSH，分派开始于 `exec` 通道请求进入已认证传输之前。使用 `want reply=true`。RFC 4254 说明服务器随后会返回通道成功或失败，但通道成功只表示服务器接受了启动命令的请求。它不表示命令已经完成，也不说明命令造成的影响可以安全重复。

分派后的取消不是预检失败。如果调用方超时并关闭通道，远程进程可能继续运行。把本地原因报告为 `CALLER_CANCELLED`，并保持 `outcome=unknown`，直到记录到能够确定远程结果的证据。取消描述的是调用方是否还关心结果，不是远程状态。

批量请求需要为每个成员记录结果。如果代理在一个 HTTP 请求中发送五项变更，而完整响应只确定了其中四项，就不能安全地为整个批次分配一个重试值。记录父级传输结果以及五个子结果。只重试记录和远程约定允许重试的子项；如果远程 API 保证变更的原子性，则核对整个批次。

通过 SSH 发送的 shell 脚本也遵循同一原则。一个退出状态覆盖的是脚本进程，不一定覆盖它尝试的每项副作用。如果调用方需要操作级别的重试决定，应为每项操作设置独立的远程标识符和结果记录，不能根据 stdout 猜测进度。

## 完整的远程结果才能确定执行状态

一个可信且帧结构完整的响应，会把不确定状态转为已知结果。对于 HTTP，记录最终状态、选定的非敏感请求头、完整正文或正文摘要，以及帧是否完整。对于 SSH，记录命令是否被接受、stdout 和 stderr 的完整状态、存在时的退出状态或退出信号，以及通道关闭情况。

RFC 9112 要求客户端在连接过早关闭或分块解码失败时，把 HTTP 响应记录为不完整。密钥代理应该更严格地执行这条规则：即使开头的字节看起来像合理的 JSON，也绝不能把部分正文作为完整远程结果。只能把部分内容放在明确标记的诊断字段中；如果其中可能含有敏感数据，则直接丢弃。

仅靠 HTTP 状态不能判断重复应用操作是否安全。完整的 401 证明服务器拒绝了该请求使用的凭据，所以代理可以把结果标为 `rejected`；使用相同凭据重试没有意义。完整的 429 或 503 可以在方法允许安全重复且响应给出合适等待时间时使用 `backoff`。完整的 500 是一个确定结果，但应用可能在产生它之前已经改变了状态。不能把每个 5xx 都转换成重放 POST 的许可。

RFC 9110 按多次相同请求的预期效果定义幂等性，并允许通信失败后自动重试幂等方法。这里重要的限定条件是“已知具有幂等性”。方法名称是证据，不是魔法。一个设计错误、会触发部署的 GET 接口，尽管方法标记为 GET 也不安全；一个正确实现的 PUT 即使会改变状态，也可以重复执行。

HTTP 信息性响应不能确定操作结果。`100 Continue` 允许客户端发送请求正文，但它完全没有说明最终的应用结果。其他 1xx 响应同样表示调用仍在进行。只有完整的最终响应，或者代理理解其约定的更强应用回执，才能使结果离开 `unknown`。

响应完整性必须与响应真实性同时成立。来自错误 TLS 身份、不受信任的 SSH 主机或意外代理的响应，即使帧结构完美，也不能成为目标系统的可信证据。身份验证通常在连接设置期间完成，但恢复的会话和连接池仍需要把已验证的对端身份绑定到操作记录。

RFC 9209 为只收到下一跳部分响应的中间节点定义了 `http_response_incomplete`。它建议使用 502，便于兼容 HTTP，但单独一个 `502` 会丢掉结果证据。任何映射后的状态码旁边，都应保留代理结构化的 `outcome=unknown`。

SSH 中也有类似陷阱。RFC 4254 建议服务器返回 `exit-status`，但不作强制要求。如果通道在输出 stdout 后关闭，却没有退出状态或退出信号，代理知道数据流已经结束，却不知道命令是否成功。此时应返回 `REMOTE_RESULT_INCOMPLETE` 并选择 `unknown`，除非操作约定定义了其他可信的完成标记。

## 结果交付失败时不能重新执行

只有代理持久化保存了已经确定的远程结果后，结果交付阶段才开始。如果给调用方序列化结果失败、MCP 管道关闭或调用方进程退出，远程操作不会重新变成未知。应报告 `phase=result_delivery`，保留 `outcome=committed` 或 `rejected`，并设置 `retry=fetch_result`。

这一阶段需要一个调用标识符，让调用方能够取回已经保存的结果。重复提交操作不等于取回结果。必须把两者分开，使通用客户端库无法把响应管道中断意外变成第二次远程调用。

写入顺序非常重要：

1. 完成并验证远程响应。
2. 把已确定结果和结果摘要追加到持久化记录。
3. 以调用标识符提交可取回的结果。
4. 把结果交付给调用方。

如果第 4 步失败，第 2 和第 3 步能证明发生过什么。如果代理先交付、后写日志，一次崩溃可能让调用方已经拿到成功结果，而审计记录仍显示未知。即使这没有立即造成重试，它仍然是审计缺陷。

大型或流式结果也要遵循同一规则。按序列号保存数据块，并设置最终完整标记。调用方可以从最后一个已验证数据块继续接收，但在代理拿到结束符、声明长度或协议特有的结束证据之前，绝不能把结果标记为完整。

调用方确认适合用于决定保留期限，不能决定远程结果。只有面向调用方的协议确认完整交付后，才记录 `RESULT_DELIVERED`。如果协议没有确认机制，应在保留策略到期前持续提供结果，并把重复获取视为读取操作。绝不能因为代理选择不保留结果，就重新运行操作来重建结果。

远程操作完成后，结果存储仍可能失败。如果代理在内存中拥有完整响应却无法提交，它知道的情况比普通 `unknown` 更多，但证据无法经受重启。此时返回 `phase=result_delivery`；只有持久性约定允许当前记录支持这一结论时，才能包含 `outcome=committed`，同时必须立即要求运维人员处理。正确的修复方法是预留容量并测试存储故障，而不是重试远程操作。

## 幂等性来自远程约定

只有当远程服务承诺把幂等键绑定到一个逻辑操作时，幂等键才能让未知结果变得可重试。在代理中生成一个 UUID 并记录下来，本身没有任何作用。远程端点必须接受该键、比较请求指纹、在足够长的时间内保留第一个确定结果，并在请求重复时返回该结果。

代理应在分派前保存四项事实：幂等键、请求指纹、远程作用域，以及服务发布的过期时间或保留信息。重试时必须复用同一个键和完全相同的指纹。使用同一个键却改变请求正文，应在本地以 `IDEMPOTENCY_MISMATCH` 失败。

不要向没有声明相关语义的端点悄悄添加幂等请求头。有些服务会忽略未知请求头，另一些会按账户或路由限定键的作用域。重试策略需要经过配置和审核的远程约定知识，不能只凭一个请求头名称寄托希望。

保留窗口属于约定的一部分。如果服务一天后就忘记这些键，超过期限的重试可能产生新的影响，同时在代理看来却完全相同。记录最早的安全过期时间，在到期前停止自动重试，之后改为核对。服务没有发布保留保证时，只能在保守配置的窗口内把该键视为有效。

并发可能击穿一个在单线程中正确的设计。两个工作进程可能同时读取同一条未知记录，并决定使用同一个键重试。正确的远程去重约定应该合并它们，但代理仍应为调用取得租约，记录重试代次，并只允许一次活跃尝试。这样既能降低负载，也能保持日志清晰。

从 `unknown` 出发只有三条安全路径：

- 重复一个语义明确为幂等的操作。
- 在已验证的远程去重约定下，使用同一个幂等键重试。
- 使用稳定的操作标识符查询远程状态，完成核对后再决定是否需要新操作。

其他情况都应停止并等待人工检查。代理可能因此需要等待，但可见的暂停比重复副作用代价更小。

条件式 HTTP 请求可以加强约定。带已知实体标签的 `If-Match` 能在资源已经变化时使更新失败，`If-None-Match: *` 则能防止在同一目标创建第二份资源。它们无法解决所有重复问题，因为端点的资源模型仍然重要，但它们提供服务器强制执行的证据，不必依赖客户端猜测。

SSH 命令很少提供协议级的幂等键。应把可重复性写进命令的应用约定：使用唯一版本标识创建部署、用原子比较方式写入，或者运行查询来确认目标状态。绝不能因为 shell 命令没有输出，就假定它可以安全重复。

## 错误信封应携带证据

调用方需要稳定的机器约定和简短的人类可读信息。传输库异常的名称会随平台变化，也会暴露实现细节，因此应把它放在内部诊断字段中。公开错误信封应类似下面这样：

```json
{"invocation_id":"act_01J...","error":{"code":"REMOTE_OUTCOME_UNKNOWN","phase":"remote_execution","outcome":"unknown","retry":"same_idempotency_key","message":"Connection closed before a complete response was recorded."},"evidence":{"dispatch_started":true,"request_complete":true,"response_headers_received":false,"response_complete":false,"idempotency":{"key":"req_01J...","scope":"payments.create","fingerprint":"sha256:8b1...","remote_contract":"configured"}}}
```

把 `code`、`phase`、`outcome` 和 `retry` 设为封闭枚举。可以增加新的证据字段，但不能改变已有字段的含义。调用方根据枚举分支处理，并把 `message` 展示给用户。它不应解析消息文本。

证据必须说明代理如何得出结论，不能只把结论换个说法。有效字段包括尝试次数、连接标识符、分派日志序号、请求指纹、协议完成标记、远程请求标识符、响应摘要、退出状态和结果记录标识符。应排除密钥、完整授权请求头、私钥以及未经筛选的远程正文。

把状态转换保存为仅追加事件，再投影出当前状态：

```text
ACTION_ACCEPTED
PREFLIGHT_VALIDATED
CREDENTIAL_AUTHORIZED
DISPATCH_STARTED
REQUEST_SENT
REMOTE_RESPONSE_STARTED
REMOTE_RESPONSE_COMPLETE
RESULT_COMMITTED
RESULT_DELIVERED
```

在 `PREFLIGHT_VALIDATED` 后结束的操作可以证明从未尝试。在 `DISPATCH_STARTED` 后、可信完成事件前结束的操作仍处于未知状态。包含 `RESULT_COMMITTED` 的操作可以在交付失败后继续存在，而无需再次远程执行。

投影过程必须拒绝不可能的状态倒退。晚到的证据可以把 `unknown` 改成 `rejected` 或 `committed`，但 `committed` 不能变成 `not_attempted`。第二个观察者可以附加核对结果，却不能重写原始尝试，假装分派从未发生。

应记录单调递增序列号，不能依赖墙上时钟的顺序。时钟会跳动，并发组件的事件也可能迟到。时间戳有助于运维人员关联系统，但日志序列决定持久化转换的先后。对于导入的远程证据，应附上来源和本地序列，而不是把它插入历史中间。

证据还需要声明可信来源。`transport_observed`、`remote_response`、`remote_query` 和 `operator_attested` 能让后续代码知道结果为何改变。运维人员在检查远程系统后，可以合理地确定一次未知尝试的结果，但这一事实不能伪装成代理在原连接中收到的响应。

审计完整性和结果证据解决的是不同问题。哈希链可以证明已记录事件之后没有被篡改，但不能证明代理记录了所有事件，也不能证明远程应用遵守了请求。Sallyport 的只写加密哈希链审计日志，以及独立的会话和活动视图，为保存这些转换提供了持久位置；操作结果仍需要本文描述的阶段和结果约定。

## 重试代码应该平淡且可测试

重试引擎应使用已经根据证据得出的处理方式。它可以增加速率限制和尝试次数上限，但不能因为某个异常看起来是暂时性的，就把不安全的处理方式升级为可重试。

```text
decide(record, operation):
  if record.retry == "fetch_result":
    return FETCH(record.invocation_id)

  if record.outcome == "not_attempted":
    if record.retry == "backoff":
      return RETRY_NEW_ATTEMPT
    return STOP_FOR_CORRECTION

  if record.outcome == "unknown":
    if operation.idempotent:
      return RETRY_NEW_ATTEMPT
    if record.retry == "same_idempotency_key" and
       operation.fingerprint == record.fingerprint:
      return RETRY_SAME_KEY
    return RECONCILE

  if record.outcome == "rejected" and record.retry == "backoff":
    return RETRY_WHEN_ALLOWED

  return RETURN_RECORDED_RESULT
```

应测试状态转换，而不是异常类别。在访问凭据前、TLS 期间、第一次请求写入前、完整请求写入后、响应头传到一半时、带帧正文传到一半时、结果提交后以及向调用方交付期间注入故障。在每一对持久化转换之间强制终止代理，并验证恢复流程绝不会在 `DISPATCH_STARTED` 后声称 `not_attempted`。

还要加入恶意或异常的远程行为。让服务器应用副作用后直接关闭，不返回响应；让它提交后返回 500；让它分别正确处理幂等键、忽略幂等键，以及拒绝使用相同键但正文不同的请求。对于 SSH，可在接受 `exec` 后关闭，省略 `exit-status`，或者先发送退出状态再中断输出流。每一次预期结果都应跟随证据。

指标应分别统计阶段和结果。`connection_setup/not_attempted` 激增通常指向路由、证书或认证问题。`remote_execution/unknown` 激增则要求核对远程状态，并可能暴露远程可靠性问题。把它们合并为“代理失败”，既隐藏运维原因，也隐藏重复操作风险。

不要让友好的 SDK 擦掉这套约定。如果它必须抛出异常，应附上完整错误信封，并且只对 `backoff` 或 `same_idempotency_key` 开放自动重试。调用方必须写出非常显眼的不安全代码，才能重复一个 `unknown/reconcile` 操作。

恢复测试还应包括竞争工作进程和过期租约。让一个工作进程取得重试租约后暂停，等租约过期，再启动另一个。当第一个进程恢复时，代次检查必须在分派前阻止它。如果没有这项检查，即使结果分类完全正确，也可能产生并发重复。

把重试预算写入操作记录，不能只放在进程本地计数器中。重启不得重置尝试次数，也不得重置远程键的过期时间。预算耗尽后，返回最后一份证据并要求核对；如果改成普通的“超过最大重试次数”，反而会丢失更安全的诊断信息。

最难处理的状态本来就应该让人不舒服。当记录表明分派已经开始，却没有收到可信的完成结果时，代理确实不知道远程结果。保留这一事实，进行核对，并拒绝把缺失的证据当成允许重试的理由。
