超时不等于失败
实验性项目
AgentPay 尚未经过独立安全审计,请勿用于生产环境的真实资金。本页示例均基于测试网和测试代币。
一句话结论
超时只说明"你没有收到结果",不说明"事情没有发生"。 在涉及资金的系统里,遇到超时必须先查清楚发生了什么,而不是直接重试。
一个具体的故事
还是那个调研 Agent。这次它调用一个行情接口,价格 0.1 USDC(100_000 个最小单位)。
1. Agent 请求接口 → 收到"需要付款"的要求
2. Agent 签名授权并重新请求 → 付款已提交
3. 链上扣款成功 ✅
4. 卖方的服务处理完毕,准备返回数据
5. 响应在网络中丢失 / 卖方响应超时 ⏱️
6. Agent 收到超时错误Agent 看到的只是一个超时。它的重试逻辑认为"没成功,再来一次":
7. Agent 再次请求,再次付款
8. 第二笔也成功 ✅结果:付了两次钱,其中一次对应的服务其实已经交付过了。 这就是重复扣款(Double Charge)。
问题的根源是一个错误的等式:
❌ 超时 = 失败 → 可以重试
为什么会这样
一次付款至少涉及三件相互独立的事情:
| 环节 | 回答的问题 |
|---|---|
| 付款意图 | 这笔付款请求本身是否被接受、是否在有效期内 |
| 资金结算 | 钱是否真的划转了(链上交易,或平台钱包扣费) |
| 服务交付 | 卖方的上游服务是否真的执行并返回了结果 |
它们发生在不同的系统里,状态各自推进。超时可能发生在任何两个环节之间:
- 钱已经扣了,但你没收到数据;
- 请求已经发给上游,但上游正在处理,还没返回;
- 交易已广播,但你查不到它是否上链。
在这几种情况下,"没收到响应"对应的真实状态完全不同,用一个"失败"概括它们,必然会出错。
常见的几种处理方式,为什么不行
方式一:超时就当失败,重新签名重试
这正是上面的故事。每次重新签名都是一份新的授权,新授权会产生新的扣款。
方式二:超时就当成功
反过来也不对。如果请求其实根本没有送达,买方付了钱却什么都没拿到。
方式三:超时后立刻释放预算
假设客户端为这次付款预留了额度。签名一旦发出,这份授权在到期前仍然可能被卖方或清算方提交上链。如果此时把预算释放掉,同一笔钱就可能被再花一次:
预留 0.1 → 签名发出 → 超时 → 立刻释放预算 → Agent 再花 0.1
↑
原来那份签名随后被提交上链,实际支出 0.2方式四:无限重试直到成功
没有节制的重试,在对方真的故障时会演变成重试风暴,同时放大上面所有风险。
AgentPay 的做法
核心思路只有一句话:把"结果不确定"当作一个明确的状态,而不是错误。 处于这个状态时,系统只允许"查询"和"等待",不允许"再付一次"。
超时 / 异常
│
▼
结果未知(明确状态)
│
┌────────┴────────┐
▼ ▼
查询真实状态 不允许盲目重试
│
┌───┼─────────┐
▼ ▼ ▼
已完成 确认未发生 仍无法判断
│ │ │
继续 安全释放 保持锁定,
预算 转人工核查具体来说,有四条规则。
1. 签名发出后,预算不会因为网络问题被立即释放。 必须同时满足两个条件才会释放:授权已经过期(含一小段安全余量),并且确认它没有被结算。在此之前,这份预算保持占用。
2. 结果未知时,禁止盲目重新提交。 如果结算处于结果未知状态,系统只做状态查询和对账,不会再次提交支付。
3. 用同一个幂等键重试,不会重复扣费。 客户端为一次业务请求带上固定的 Idempotency-Key。网关识别到同一个键,会直接复用上一次的结果或已冻结的额度,这被称为"免费重放"。如果同一个键被拿去提交内容不同的请求,则会被拒绝,防止篡改。
4. 付款成功与服务交付分开记录。 链上结算成功但上游交付失败时,系统会保留"已付款、待补偿"的记录,并支持免费重试交付,而不是简单地标记成失败。
平台钱包模式下的处理
使用平台虚拟钱包时,流程是"冻结额度 → 调用上游 → 确认扣款或释放"。不同的上游结果对应不同的处理:
| 上游返回 | 处理 |
|---|---|
| 成功(2xx) | 确认扣款 |
| 明确的客户端错误(4xx) | 释放冻结,不扣费 |
| 服务端异常(5xx)或超时 | 保持冻结,既不扣款也不释放,转人工核查 |
5xx 和超时不能直接释放,因为上游可能已经执行了一部分,甚至已经执行完毕。保持冻结是最保守的选择:资金暂时不可用,但不会多扣,也不会白送服务。冻结有超时时间,到期后由恢复流程处理。
客户端该怎么写
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)关键点只有一个:重试时必须复用同一个幂等键。 每次重试都生成新的键,等于告诉网关"这是一笔新请求"。
效果:把开头的故事重放一遍
1. Agent 付款并请求,卖方成功收款
2. 响应丢失,Agent 收到超时
3. Agent 带着同一个幂等键重试
4. 网关识别为同一笔请求 → 返回已有结果,不再扣款最终只扣一次,数据也拿到了。
常见问题
Q:为什么不让系统等久一点,直接拿到明确结果? 网络和链上的确认时间不可控,无限等待会拖垮 Agent。所以做法是:先返回"处理中或结果未知",再由后台对账补全最终状态。
Q:幂等键应该怎么生成? 用业务上能唯一标识"这一次意图"的值,例如任务 ID 加步骤编号。同一个业务动作的所有重试用同一个键,不同的业务动作用不同的键。
Q:保持冻结会不会一直占用资金? 不会一直占用。冻结有超时时间,到期后由恢复流程处理;需要人工判断的,也会在控制台里标记出来。
Q:这能保证绝对不会重复扣款吗? 这些规则大幅降低了风险,并有故障注入测试覆盖(上游 5xx、网络中断、进程崩溃)。但项目仍处于实验阶段,未经过独立安全审计,不应作为"绝对保证"来依赖。
下一步
- 想理解"并发下预算为什么要原子预留":预算预留
- 想理解"付款、结算、交付为什么要分开记录":三态模型
- 想看到故障时的实际表现:读懂一笔支付的全链路
