# 代理工具超时：防止问题重演的恢复方案

超时描述的是客户端观察到的情况，不是对操作结果的判决。调用方只是放弃了等待，仅此而已。当代理把这种观察直接标记为「失败」，然后重试一个会改变状态的调用时，就可能造成第二笔付款、第二次部署、第二张支持工单，或者让远程命令在一台已经承受压力的机器上运行两次。

代理工具需要超时恢复方案，因为代理的行动速度比监管它的人快，而且往往会把工具输出当成事实。没有响应不等于事实。恢复路径必须决定是重试、等待、查询证据，还是停止并请求人工决策。在允许代理发起重要调用之前，就应先建立这条路径。

## 超时会留下三种可能的历史

客户端超时后，请求通常对应三种历史之一：服务从未收到请求，服务收到了请求但尚未完成，或者服务已经完成操作但客户端没有收到结果。网络故障可能发生在连接建立之前、请求正文传输期间、服务处理期间，或者响应返回期间。同一种异常类型可能覆盖这四种情况。

这个区别会改变下一步行动。如果 DNS 查询在建立连接前失败，重试可能合理。如果服务已经接受了删除资源的请求，但响应消失了，重试可能会再次执行破坏性操作。如果服务把任务放入异步队列，第二次提交可能会创建一个与第一次竞争的任务，而第一次仍在运行。

HTTP 没有提供一个神奇的标记，让客户端知道究竟发生了哪种历史。RFC 9110 说明了请求方法和响应的含义，也区分了安全方法和幂等方法，但没有承诺客户端可以从丢失的响应中推断服务器是否已经执行操作。这是物理限制，不是 SDK 缺少某个选项。

团队经常把两个不同的问题混在一起：

- 再次发送这个请求，是否不会改变预期的最终状态？
- 原始尝试是否真的到达了服务并影响了它？

幂等性解决第一个问题。结果核对解决第二个问题。系统需要两者。幂等的 `PUT` 可能可以安全重复，但超时仍然让你无法知道这个请求触发的下游工作是否已经完成。状态查询可以确认结果，但如果服务接受了两个无法区分的创建请求，它无法阻止重复工作。

代理的工具契约必须体现这个区别。一个简单的文本错误，例如 `request timed out`，很容易让代理临时发挥。结构化结果如果明确写出 `outcome: unknown`，就会告诉代理离开重试分支，进入证据分支。

## 选择重试前，先画出请求路径

有用的恢复方案会明确证据可能出现在哪些边界。先从代理进程开始，然后依次列出工具封装、连接池、网关或代理服务器、服务入口、应用、持久化存储，以及负责异步工作的任务处理器。某个边界发生超时，并不能可靠说明下一个边界发生了什么。

以创建部署的调用为例。代理通过工具发送请求。客户端写完请求正文，服务器提交了部署记录，接着连接断开，响应没有到达客户端。工具返回超时。代理用一个全新的请求重试。现在服务器上有两条部署记录，从各自的角度看它们都有效。

再改变一个细节：客户端在上传正文时超时，服务器在应用代码运行前拒绝了不完整的正文。同一个工具仍然可能返回 `timeout`。这时重试可能刚好只创建一个部署。仅凭超时，调用方无法区分这两种情况。

写下每个组件能够提供的证据。对于典型的 HTTP 操作，证据包括：

- 客户端时间戳、选定的目标、请求正文摘要，以及调用方生成的操作 ID。
- 显示入口是否接受请求的服务访问日志。
- 将操作 ID 与已提交结果一起保存的应用记录。
- 对于同步请求结束后仍会继续的操作，记录任务处理器或队列信息。
- 返回当前状态或操作状态的读取端点。

不要把网络日志当作唯一事实来源。负载均衡器日志可以显示字节已经到达，但不能证明数据库事务已经提交。数据库记录可以证明提交成功，但不一定能证明外部提供商收到了之后的副作用。权威记录必须与要证明的操作相匹配。

对于发送邮件，提供商返回的已接受消息 ID，比应用日志中写着「准备发送」更有力。对于数据库迁移，迁移表或事务记录，比调用方没有收到的 shell 进程退出码更有力。对于创建云资源，带有调用方生成 ID 的操作 URL 或资源标签，比再次发送创建请求更好。

## 幂等性必须属于操作，而不是某次尝试

只有当同一个预期操作的每次尝试都携带相同的持久化标识时，重试方案才有效。在第一次网络调用之前生成这个标识，并将它与操作说明一起保存。在进程重启、工具重启或交接给人工操作员后继续使用它。

不要在重试循环中生成新的 UUID。这种写法在代码审查中看起来很谨慎，却会破坏整个目的。服务器会把每次重试都看成新请求，这正是重复操作出现的原因。

请求可以根据 API 的设计，在请求头或正文中携带幂等值。传输细节不如服务器规则重要。服务器必须以原子方式将这个值与操作及其结果关联起来。如果两个相同请求同时到达，服务器必须将它们串行处理，或让其中一个观察到另一个的结果。若缓存会在延迟重试完成前过期，就无法可靠防止重复。

一个实际的 HTTP 请求可能如下所示：

```http
POST /deployments HTTP/1.1
Content-Type: application/json
Idempotency-Key: op_7d5d4d8e4e5a
X-Correlation-ID: run_42_task_9

{"repository":"api","revision":"a1b2c3d4","environment":"staging"}
```

服务器应将幂等值、重要请求字段的指纹，以及生成的部署或操作 ID 一起保存。如果同一个值再次出现，但 revision 或 environment 不同，就应拒绝请求。对于不同的正文仍返回第一次结果，会悄悄执行错误的意图。

对于异步操作，服务器接受任务后，应尽快返回持久化的操作引用：

```json
{
  "operation_id": "dep_1842",
  "state": "accepted",
  "status_url": "/operations/dep_1842"
}
```

超时后，代理应先查询 `op_7d5d4d8e4e5a` 或 `dep_1842`，再考虑再次提交。如果 API 既不支持幂等值，也不支持按外部引用查询，就应在设计上把这类写操作归为结果有歧义。对于一次性的测试资源，这或许可以接受。对于会产生费用或改变生产状态的自主操作，这种设计并不合适。

不要因为使用了 `POST` 和重试库，就把某个方法称为安全。HTTP 方法名只是对预期语义的提示，无法防止服务器实现重复工作。应阅读具体 API 文档，然后亲自测试重复请求的行为。

## 为代理提供明确的未知结果状态

对于可能影响外部世界的工具，代理不应只收到 `success` 或 `error`。它还需要第三种结果：`unknown`。这个状态能阻止最危险的代理行为，也就是把不完整的记录当成许可，再尝试一个略有不同的同类命令。

使用一种能记录失败阶段，同时承认阶段可能不确定的结果契约。例如：

```json
{
  "outcome": "unknown",
  "operation_id": "op_7d5d4d8e4e5a",
  "correlation_id": "run_42_task_9",
  "transport_observation": "response deadline exceeded after request write",
  "retry_allowed": false,
  "reconcile": {
    "method": "GET",
    "path": "/operations/by-id/op_7d5d4d8e4e5a"
  }
}
```

`retry_allowed` 字段必须来自工具或操作定义，不能由代理根据英文动词自行猜测。代理不能因为目标碰巧是 staging 环境，就安全地推断 `create_release` 没有风险。staging 部署仍可能发送通知、消耗共享配额或改变发布渠道。

让代理遵循有边界的恢复顺序：

1. 在运行记录中保存预期操作、操作 ID、目标和超时观察结果。
2. 使用相同的操作 ID 或服务发放的引用，查询权威状态来源。
3. 只有确认得到终止状态后才继续。只有当操作定义允许重试，且状态来源显示没有已接受的操作时，才进行重试。
4. 如果服务在操作的恢复截止时间内无法确认结果，就停止并呈现证据。

停止条件很重要。无限轮询会持续消耗注意力，让任务在原本目的已经消失很久后仍然运行。代理尝试五种写请求变体，可能会给别人留下一个清理项目。每个操作都应有独立于请求截止时间的恢复截止时间。

人工批准本身无法消除未知结果。批准回答的是「这个调用方是否可以尝试此操作？」它没有回答「之前的尝试是否成功？」授权证据和执行证据应在界面和日志中分别保存。

## 读取超时和写入超时需要不同处理

读取超时通常没有那么严重的重复风险，但仍可能导致代理做出错误决定。代理可能在列出资源时超时，随后从其他地方收到不完整或过期的缓存响应，并得出资源不存在的结论。接着它尝试创建资源，结果与现实状态冲突。

根据读取所支持的决策对其分类。无害的仪表板刷新可以使用有边界的退避重试。用于决定是否发起写操作的读取，则需要明确的一致性规则。如果服务提供 ETag、版本号、代数值或读后写状态端点，就使用它。如果服务只有最终一致性，就让代理清楚看到等待时间和失败条件。

避免使用统一的重试次数。短暂的元数据查询可能适合快速重试两次。加载数据仓库的报表查询可能需要一个较长的截止时间，而且不应立即重复。返回 `429 Too Many Requests` 或明确的服务重试值，需要与 socket 超时不同的处理方式。把所有错误都当成临时网络问题，会让代理把局部中断变成可以避免的负载。

退避有助于保护服务，但不能解决歧义。它只会让重复请求之间的间隔更长。对于重要写操作，应将退避与幂等值或状态检查配合使用。

在 API 支持时，使用条件写入。带有已知 ETag 的 `If-Match` 请求，可以防止代理覆盖读取之后已经发生变化的资源。支持客户端选择资源 ID 的创建操作，可以让重试最终落到同一个对象上。这些机制能保护状态转换，但不能替代对资源之外副作用是否发生的记录。

## SSH 会把远程执行隐藏在一条断开的数据流后面

SSH 超时恢复需要比 HTTP 恢复更加谨慎。SSH 会话可能在远程主机启动命令后、输出传输期间、命令退出后，或者父进程断开而子进程继续运行时丢失。本地 shell 的消息无法告诉你究竟发生了哪一种情况。

危险的模式是运行一条很长的复合命令：

```sh
ssh deploy@host 'download-release && migrate-db && restart-service'
```

如果连接在 `migrate-db` 之后断开，重新运行完整命令可能会再次执行迁移，或者重启一个新版本尚未下载完成的服务。终端记录把多个状态转换压缩成了一个无法拆解的结果。

把远程工作拆成带有持久化、可检查标记的操作。部署可以在开始前记录 release ID，将迁移版本保存到数据库，并通过本地状态命令公开当前 revision。恢复工具重新连接后，应先询问这些标记，再进行其他操作。

例如，代理可以使用一个专为机器而非人设计输出的远程状态命令：

```sh
ssh deploy@host '/usr/local/bin/release-status --json'
```

```json
{
  "release_id": "rel_202",
  "phase": "migrated",
  "active_revision": "9f24c1",
  "migration_version": "20250308_02"
}
```

现在，恢复操作有了决策依据。如果 `phase` 是 `migrated`，就不要再次运行迁移。如果主机没有报告 `release_id`，只有在远程命令能够保证「不存在」意味着之前没有执行时，代理才可以启动操作。如果 SSH 无法重新连接，正确结果仍然是未知。对于会改变生产主机的操作，不要用乐观的重跑来替代未知结果。

谨慎使用远程锁。锁可以防止并行运行，但主机故障后留下的过期锁可能阻碍恢复。将操作 ID 和过期策略写入锁记录，并确保可以检查锁，而不是盲目删除。删除所有旧锁的清理命令本身也是状态变更操作，也需要自己的证据。

Sallyport 可以让 SSH 凭据留在代理进程之外，由内置的 `sp-ssh` 辅助工具完成连接，但凭据隔离并不会让断开的会话可以安全重放。远程命令仍然需要操作 ID、持久化标记和结果核对路径。

## 审计记录有助于调查，但不能证明操作完成

操作日志应保留足够的信息，让人可以在不保存机密的情况下重建意图和恢复过程。记录调用方会话、时间、目标身份、操作 ID、请求摘要或命令模板、授权事件、传输结果和最终核对结果。不要记录 bearer token、私钥，或可能包含凭据和个人数据的原始请求正文。

将尝试过的操作和已完成的操作分开。写着 `POST /deployments timeout` 的日志行是尝试记录。之后的查询返回 `succeeded` 状态的操作，才是完成证据。两者都要保留。用最终成功替换第一条记录，会抹去事故中最有用的部分，也就是调用方不知道结果的那段时间。

当代理运行引发后续争议时，防篡改证据很重要。你需要回答：哪个进程发起了调用，它获准做什么，是否收到了结果，以及团队如何确认最终状态。可变的活动表很容易搜索，但如果被入侵的进程能够改写历史，它的证据力就很弱。

Sallyport 使用不可读取明文的加密哈希链审计日志记录代理会话和单次调用，`sp audit verify` 可以在线下通过密文验证哈希链。这个记录可以显示网关操作和调用方历史，但远程服务或主机仍然是判断预期工作是否完成的权威来源。

不要把网关审计事件和应用事务混为一谈。网关记录了出站请求，并不代表请求没有在服务提交之前失败。如果服务已经提交，网关也可能从未看到响应。只有当两边的记录共享操作 ID 或关联 ID 时，调查才更可靠。

## 在事故替你验证之前，主动测试歧义

从未经历过响应丢失的超时方案，只是一种假设。应测试一种确切的故障：服务完成了操作，但客户端丢失了结果。很多团队跳过这个场景，因为普通的成功路径测试替身无法表达它。

建立一个测试端点或代理测试装置，让它接受请求、提交持久化记录，然后延迟或丢弃响应。用同一个操作 ID 发送两次。确认服务返回一个逻辑操作，代理在超时后查询状态，审计记录保留两次尝试以及结果核对过程。

然后测试相反的情况：在服务接受请求前中断请求。确认只有在找不到操作记录后，恢复流程才可以重试。这两项测试可能产生同一种客户端异常，但工具契约必须根据服务端证据的不同，引导出不同的行动。

还应测试这些情况：

- 服务接受操作，但其任务处理器在代理恢复截止时间之后仍然处于等待状态。
- 两个代理进程几乎同时提交同一个操作 ID。
- 状态端点不可用，但主要写入端点正常。
- SSH 命令启动了子进程，然后连接在最终输出前结束。
- 人工恢复暂停的运行时，另一名操作员已经核对过该操作。

最后一种情况能发现真实运维中常见的问题：恢复状态必须存在于代理的对话记忆之外。将操作 ID 和当前发现保存到持久化运行记录中。重启后的代理应读取这份记录并继续核对，而不是因为看不到之前的记录，就凭空发起新操作。

对于超过恢复截止时间的未知结果，设置告警。如果普通重试可以解决安全的读取操作，就不要为每次第一次超时都告警。当重要写操作没有确认的终止状态、同一个操作 ID 携带不同正文，或远程标记与预期进度相矛盾时，应发出告警。这些情况需要人工介入，不能让代理继续行动。

## 恢复方案应让拒绝成为正常选择

最可靠的超时处理往往是拒绝：「我无法确认部署请求是否完成，因此不会再次提交。」这不是工具失败。对于不可逆或成本高昂的操作，在缺少证据时，这才是正确响应。

让这个响应真正有用。显示操作 ID、目标、最近一次确认的状态、时间戳，以及能够解决问题的具体状态查询或远程检查。如果不存在权威查询，就明确说明，并把决定交给了解重复操作后果的人。

团队会抗拒这种做法，因为重试让人感觉是在推进，而暂停让人感觉缓慢。经历足够多的重复写入和半成品部署后，取舍就会变得清楚。花一分钟核对结果，比发现两个系统都认为自己完成了代理本来只被要求执行一次的操作，代价低得多。

先从会产生资金流动、外部消息、发布、访问权限变更和数据删除的写操作开始。对每一项操作，都向 API 负责人提出三个问题：哪个标识能把重试绑定到同一个操作，调用方可以在哪里查询结果，连接断开时有什么证据。如果缺少任何一个答案，就让工具返回 `unknown`，并要求人工有意识地做决定，不要教代理猜测。
