幂等键命名的是一次预期行动,不是一次网络投递尝试。服务支持这项契约时,客户端在结果不明确后重试同一个行动,应该继续发送同一个键。每次重试都生成新键,实际是在告诉服务器“这是另一次行动”,因此完全可能合理地产生第二份业务效果。客户端必须先把行动身份与尝试次数分开。
创建一次,在后续尝试中保留
在第一次发送之前生成键,并把它与操作参数、目标一起保存。如果后台任务可能在客户端重启后恢复,任务记录也需要保留这个身份。仅把键放在临时网络连接或一次调用栈中,无法可靠地把后续尝试关联回原来的意图,恢复程序也不应临时猜一个替代值。
真正的新行动应该使用新键,即使参数与之前的购买完全一样。仅用正文哈希无法区分“再买一次同样的东西”和“重试第一次购买”。键与请求指纹承担不同职责:键标记行动,指纹检查是否发生了不兼容的复用。把二者合并,既可能漏去重,也可能错误阻止合理的新操作。
键的格式和作用域应遵循服务提供方的契约。Stripe 建议使用 UUIDv4 等随机标识,并提醒不要把敏感数据放进键中。键不是认证凭据,拥有某个键也不能代替访问已存结果所需的授权。不要把 bearer 凭据、邮箱或个人信息塞进用于关联请求的标识。
接收方必须真正实现这份承诺
只加一个 Idempotency-Key 请求头,不会自动实现去重。服务端必须在明确契约下识别它,说明比较哪些参数、保留哪些结果、执行尚未完成时如何处理重复请求,以及记录会保存多久。这些都是服务行为,不能从客户端看见一个字段名就推断已经具备。
Stripe API v1 记录的是首次进入执行阶段的请求状态码与正文,包括 500 响应。如果参数验证失败,或与正在执行的请求发生冲突,而端点执行尚未开始,则不会保存对应幂等结果。因此,“重复请求必须等待第一份结果”并不是所有 API 都必须采用的通用规则,调用方要处理实际的在途冲突语义。
Stripe API v2 则可能继续尝试失败或部分失败的工作,并返回更新后的结果;不能把 v1 的原响应保存规则直接套到 v2。不同提供方也不保证逐字重放响应。EC2 文档说明,成功重试不会再次执行行动,但返回结果可以包含更新后的创建状态等信息。RFC 9110 定义的是预期效果;具体服务是否复用原始响应字节,是叠加在其上的另一层契约,不能把两者当成同一要求。
让键的含义保持稳定
同一个键被配上不兼容的参数时,应按照服务明确的错误规则处理,而不是静默创建另一项操作,或覆盖原先保存的结果。JSON 字段顺序、默认值、数字表示和省略字段,都可能影响比较。一个小例子能规范化某种输入,不代表已经解决任意正文的语义等价问题。
下面的实验只接受一个商品字符串和一个正整数量。将这两个字段排序,定义了它自己的比较规则,并不是通用 JSON 等价算法。示例用 409 表示参数冲突,也是测试接口的政策,不是每一家幂等 API 都保证返回的状态码。
坏掉的样子
超时不能证明操作没有执行。使用新键重试,可能把一次已经成功但丢了响应的购买变成两次。为了绕过已保存的错误而换键,也可能在原行动结果仍不明时额外启动工作。应该按照服务文档给出的恢复路径核对原行动,而不是用新身份掩盖不确定性。
保留期同样限制承诺范围。Stripe API v1 允许在键至少保存二十四小时后清理,并将清理后的同键请求视为新请求;这既不是永久保存身份,也不是适用于所有服务的精确过期定时器。客户端的恢复窗口必须与实际保留契约一致,不能只考虑立即发生的重试。
快速验证
保存为 intent_check.py,用 Python 3.10 以上版本执行 python3 intent_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。代码在临时目录中写入一份行动记录,再读回,退出时清理目录。API 与响应丢失都是明确的顺序模拟,不会发送网络请求,也不会连接真实支付服务。
import json
from pathlib import Path
from tempfile import TemporaryDirectory
from uuid import uuid4
def new_intent(parameters):
return {"key": str(uuid4()), "parameters": dict(parameters)}
class FixtureAPI:
# One already authenticated scope, sequential execution, no network service.
def __init__(self):
self.saved = {}
self.orders = []
self.attempts = []
def execute(self, intent):
key, parameters = intent["key"], intent["parameters"]
self.attempts.append(key)
if (set(parameters) != {"product", "quantity"}
or not isinstance(parameters["product"], str) or not parameters["product"]
or type(parameters["quantity"]) is not int or parameters["quantity"] < 1):
return 400, '{"error":"invalid parameters"}'
fingerprint = json.dumps(parameters, sort_keys=True, separators=(",", ":"))
if key in self.saved:
original, response = self.saved[key]
return response if original == fingerprint else (409, '{"error":"key reused with other parameters"}')
self.orders.append(dict(parameters))
response = (201, json.dumps({"order_id": len(self.orders)}, separators=(",", ":")))
self.saved[key] = (fingerprint, response)
return response
def deliver(api, intent, lose_response=False):
response = api.execute(intent)
if lose_response:
raise TimeoutError("simulated response loss after the fixture saved its result")
return response
api = FixtureAPI()
parameters = {"product": "notebook", "quantity": 2}
intent = new_intent(parameters)
with TemporaryDirectory(prefix="idempotency-intent-") as directory:
record = Path(directory) / "intent.json"
with record.open("x", encoding="utf-8") as stream:
json.dump(intent, stream)
try:
deliver(api, intent, lose_response=True)
except TimeoutError:
pass
else:
raise AssertionError("expected simulated response loss")
assert len(api.orders) == 1
with record.open(encoding="utf-8") as stream:
recovered = json.load(stream)
assert recovered == intent
first_known_result = deliver(api, recovered)
assert first_known_result == (201, '{"order_id":1}')
assert deliver(api, recovered) == first_known_result
assert len(api.orders) == 1 and api.attempts[:3] == [intent["key"]] * 3
print("one persisted intent survives serialization and three attempts produce one order")
reordered = {"key": intent["key"], "parameters": {"quantity": 2, "product": "notebook"}}
assert deliver(api, reordered) == first_known_result
changed = {"key": intent["key"], "parameters": {**parameters, "quantity": 3}}
assert deliver(api, changed)[0] == 409 and len(api.orders) == 1
assert deliver(api, recovered) == first_known_result
print("equivalent field ordering replays; changed parameters conflict without replacing the saved result")
mistaken_retry = new_intent(parameters)
assert mistaken_retry["key"] != intent["key"]
assert deliver(api, mistaken_retry) == (201, '{"order_id":2}')
assert len(api.orders) == 2
print("generating a new key for an uncertain retry creates a second order")
invalid = new_intent({"product": "notebook", "quantity": 0})
assert deliver(api, invalid)[0] == 400
assert invalid["key"] not in api.saved and len(api.orders) == 2
corrected = {**invalid, "parameters": {"product": "notebook", "quantity": 1}}
assert deliver(api, corrected) == (201, '{"order_id":3}')
assert len(api.orders) == 3
print("this fixture does not save pre-execution validation failures; the corrected request can execute")
测试先保存第一次操作结果,再模拟超时。读回保存的行动并继续使用原键,三次尝试只生成一笔订单。字段顺序变化仍重放同一结果,数量改变则发生冲突,而且原结果没有被替换。如果把不确定的重试换成新键,就会创建第二笔订单,直接展示错误客户端行为的后果。
这个测试接口不会保存执行前的参数验证失败,因此修正非法数量后,仍可用尚未执行过的那个键发起操作。这只展示已经声明的政策,不能理解为对已经执行的请求也可以随意修改参数。应先区分是否进入执行阶段,再应用具体服务的规则。
文件往返验证的是序列化后身份仍然保留,并没有启动新的客户端进程,也不证明客户端存储能抵抗崩溃。持久化服务端记录、并发执行、真实传输丢失与提供方 API 都没有在本例中验收。把应用部署称为可安全重试之前,仍需验证这些真实边界。
延伸阅读
来源
- Stripe:幂等请求,稳定键、API v1 的结果保留与执行前失败,以及 API v2 差异,已核验 2026-10-15。
- EC2 API 幂等性,客户端令牌语义与可反映更新状态的结果,已核验 2026-09-08。
- RFC 9110,9.2.2 节定义预期效果与非幂等方法重试条件,已核验 2026-09-08。