Skip to content

超时不等于失败 ​

实验性项目

AgentPay 尚未经过独立安全审计,请勿用于生产环境的真实资金。本页示例均基于测试网和测试代币。

一句话结论 ​

超时只说明"你没有收到结果",不说明"事情没有发生"。 在涉及资金的系统里,遇到超时必须先查清楚发生了什么,而不是直接重试。

一个具体的故事 ​

还是那个调研 Agent。这次它调用一个行情接口,价格 0.1 USDC(100_000 个最小单位)。

text
1. Agent 请求接口           → 收到"需要付款"的要求
2. Agent 签名授权并重新请求   → 付款已提交
3. 链上扣款成功             ✅
4. 卖方的服务处理完毕,准备返回数据
5. 响应在网络中丢失 / 卖方响应超时   ⏱️
6. Agent 收到超时错误

Agent 看到的只是一个超时。它的重试逻辑认为"没成功,再来一次":

text
7. Agent 再次请求,再次付款
8. 第二笔也成功             ✅

结果:付了两次钱,其中一次对应的服务其实已经交付过了。 这就是重复扣款(Double Charge)。

问题的根源是一个错误的等式:

❌ 超时 = 失败 → 可以重试

为什么会这样 ​

一次付款至少涉及三件相互独立的事情:

环节回答的问题
付款意图这笔付款请求本身是否被接受、是否在有效期内
资金结算钱是否真的划转了(链上交易,或平台钱包扣费)
服务交付卖方的上游服务是否真的执行并返回了结果

它们发生在不同的系统里,状态各自推进。超时可能发生在任何两个环节之间:

  • 钱已经扣了,但你没收到数据;
  • 请求已经发给上游,但上游正在处理,还没返回;
  • 交易已广播,但你查不到它是否上链。

在这几种情况下,"没收到响应"对应的真实状态完全不同,用一个"失败"概括它们,必然会出错。

常见的几种处理方式,为什么不行 ​

方式一:超时就当失败,重新签名重试 ​

这正是上面的故事。每次重新签名都是一份新的授权,新授权会产生新的扣款。

方式二:超时就当成功 ​

反过来也不对。如果请求其实根本没有送达,买方付了钱却什么都没拿到。

方式三:超时后立刻释放预算 ​

假设客户端为这次付款预留了额度。签名一旦发出,这份授权在到期前仍然可能被卖方或清算方提交上链。如果此时把预算释放掉,同一笔钱就可能被再花一次:

text
预留 0.1 → 签名发出 → 超时 → 立刻释放预算 → Agent 再花 0.1
                                             ↑
                      原来那份签名随后被提交上链,实际支出 0.2

方式四:无限重试直到成功 ​

没有节制的重试,在对方真的故障时会演变成重试风暴,同时放大上面所有风险。

AgentPay 的做法 ​

核心思路只有一句话:把"结果不确定"当作一个明确的状态,而不是错误。 处于这个状态时,系统只允许"查询"和"等待",不允许"再付一次"。

text
          超时 / 异常
              │
              ▼
       结果未知(明确状态)
              │
     ┌────────┴────────┐
     ▼                 ▼
  查询真实状态        不允许盲目重试
     │
 ┌───┼─────────┐
 ▼   ▼         ▼
已完成 确认未发生  仍无法判断
 │    │         │
继续  安全释放   保持锁定,
      预算      转人工核查

具体来说,有四条规则。

1. 签名发出后,预算不会因为网络问题被立即释放。 必须同时满足两个条件才会释放:授权已经过期(含一小段安全余量),并且确认它没有被结算。在此之前,这份预算保持占用。

2. 结果未知时,禁止盲目重新提交。 如果结算处于结果未知状态,系统只做状态查询和对账,不会再次提交支付。

3. 用同一个幂等键重试,不会重复扣费。 客户端为一次业务请求带上固定的 Idempotency-Key。网关识别到同一个键,会直接复用上一次的结果或已冻结的额度,这被称为"免费重放"。如果同一个键被拿去提交内容不同的请求,则会被拒绝,防止篡改。

4. 付款成功与服务交付分开记录。 链上结算成功但上游交付失败时,系统会保留"已付款、待补偿"的记录,并支持免费重试交付,而不是简单地标记成失败。

平台钱包模式下的处理 ​

使用平台虚拟钱包时,流程是"冻结额度 → 调用上游 → 确认扣款或释放"。不同的上游结果对应不同的处理:

上游返回处理
成功(2xx)确认扣款
明确的客户端错误(4xx)释放冻结,不扣费
服务端异常(5xx)或超时保持冻结,既不扣款也不释放,转人工核查

5xx 和超时不能直接释放,因为上游可能已经执行了一部分,甚至已经执行完毕。保持冻结是最保守的选择:资金暂时不可用,但不会多扣,也不会白送服务。冻结有超时时间,到期后由恢复流程处理。

客户端该怎么写 ​

python
import httpx

headers = {
    "Authorization": f"Bearer {api_key}",
    "Idempotency-Key": "order-20261020-001",  # 同一业务请求固定不变
}
try:
    resp = httpx.get(url, headers=headers, timeout=10)
except httpx.TimeoutException:
    # 超时后用同一个幂等键重试,不会重复扣费
    resp = httpx.get(url, headers=headers, timeout=10)

关键点只有一个:重试时必须复用同一个幂等键。 每次重试都生成新的键,等于告诉网关"这是一笔新请求"。

效果:把开头的故事重放一遍 ​

text
1. Agent 付款并请求,卖方成功收款
2. 响应丢失,Agent 收到超时
3. Agent 带着同一个幂等键重试
4. 网关识别为同一笔请求 → 返回已有结果,不再扣款

最终只扣一次,数据也拿到了。

常见问题 ​

Q:为什么不让系统等久一点,直接拿到明确结果? 网络和链上的确认时间不可控,无限等待会拖垮 Agent。所以做法是:先返回"处理中或结果未知",再由后台对账补全最终状态。

Q:幂等键应该怎么生成? 用业务上能唯一标识"这一次意图"的值,例如任务 ID 加步骤编号。同一个业务动作的所有重试用同一个键,不同的业务动作用不同的键。

Q:保持冻结会不会一直占用资金? 不会一直占用。冻结有超时时间,到期后由恢复流程处理;需要人工判断的,也会在控制台里标记出来。

Q:这能保证绝对不会重复扣款吗? 这些规则大幅降低了风险,并有故障注入测试覆盖(上游 5xx、网络中断、进程崩溃)。但项目仍处于实验阶段,未经过独立安全审计,不应作为"绝对保证"来依赖。

下一步 ​

深入阅读 ​

实验性项目,未经独立安全审计,请勿用于生产资金。