关于 api-idempotency-retry
api-idempotency-retry 是面向 API 团队的设计技能,用于共同制定服务端幂等键和客户端重试行为。它涵盖键契约、原子占用、响应重放、保留期限与 SDK 默认值,避免只实现重试或幂等的一端。
主要功能
- 01识别重试不安全的端点
- 02定义键范围与请求体匹配
- 03设置重放和保留期限
- 04原子化占用幂等键
- 05规定处理中与完成后的响应
- 06编写重试指南和 SDK 默认值
适用场景
- 01防止连接中断后重复产生操作
- 02为第三方 HTTP 集成方设计重试
- 03统一 API 端点的幂等行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ samber/ developer-platform-skills --skill api-idempotency-retry安装后在 Claude Code 中输入/api-idempotency-retry确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
evals/evals.jsonreferences/backoff-jitter-formulas.mdkey-lifecycle-walkthrough.mdvendor-sdk-retry-defaults.md详细介绍
API 幂等性与重试
你要设计一项保障的两个组成部分:服务端幂等键,让重试写入安全;客户端重试行为,则要确保这些键得到合理使用。只交付其中一部分是个陷阱——Resilience4j 自己的文档明确指出,这是硬性约束,而非建议:重试操作必须具备幂等性。因此,没有键支持的重试指南(或反过来)都会留下保障缺口,恰恰是在连接中断时暴露出来。
以下两点是全部内容的基础:
- 在不可靠网络上不可能实现恰好一次传递(Tyler Treat 的经典论证以 FLP 结果为依据)。键实际提供的是 实质上仅一次:至少一次传递加上重复请求合并,其保障范围仅限于实际实现了幂等性的部分。
Idempotency-Key标头背后没有获批的标准。IETF 草案(draft-ietf-httpapi-idempotency-key-header)于 2026 年 4 月到期,未获批准。这个标头名称在业界统一,是因为 Stripe 推广了它;但语义(TTL、不匹配时的行为、并发处理)由各家提供商自行决定,因此你必须自行设计并记录相关约定,不能想当然。
记忆(建议):如果记忆存储在文件中,可考虑使用 developer-platform-context.md;如果使用其他记忆系统,则以该系统为准。该文件仅供参考,并非强制要求。将不同任务的信息分开存放。已完成的任务应移除。为任务添加日期;一般项目背景信息则不添加日期。部分访谈回答可能因任务而异。
澄清问题
设计任何内容之前,请先询问以下问题;每个答案都会影响后续步骤。将问题打包一起询问——这是战术性设计任务,不是战略访谈。
- 范式:REST、gRPC,还是两者都有?(gRPC 原生支持截止时间传播;REST 则需要标头约定和显式的截止时间传递机制)
- 哪些端点不是幂等的?请提供端点列表或 OpenAPI 规范,并列出所有会更改状态的操作。
- 幂等键存储使用什么后端——带唯一约束和事务的关系型数据库、支持条件写入的 KV 存储,还是仅有缓存?(这会决定哪些原子认领方案可用——参见步骤 4)
- 是否有请求会在执行过程中调用不可回滚的外部系统(例如扣款、发送外发电子邮件)?(这会影响恢复点——参见步骤 5)
- 你会发布哪些语言的 SDK,集成方是否也会直接调用原始 HTTP?(这会决定你能为他们预设多少重试机制——参见步骤 6)
- 最长的重试链可能有多长——死信队列重放窗口、争议处理窗口、计划重新运行?(这会影响 TTL——参见步骤 3)
- 保障最迟需要何时生效:是一次性修复(阻止某个正在提交工单的客户端重复扣款),还是一项会由今后每个端点继承的持续性约定?认领机制的工作量上限是多少——例如,交付日期是否排除了架构或隔离级别迁移,还是团队已经熟悉 SERIALIZABLE 工作负载?(这会重新排列认领方案——参见步骤 4:硬性期限会优先考虑当前存储已有支持的方案;持续性要求会优先考虑更完善的认领生命周期;如果上限不允许进行架构迁移,就直接排除无法实现的方案,而不是将它们排在最后)
谁控制重试循环
每次重试决策都由开发者或程序做出,无论谁购买了产品。设计取决于谁控制重试循环:
- 一方开发者 - 你控制两端;内部约定加上代码审查就能解决大部分问题。这是服务成本最低的受众。
- 通过原始 HTTP 调用的第三方集成方 - 他们仅凭你的文档编写自己的重试循环。他们需要完整公开的契约:键语义、TTL、不匹配和请求处理中行为,以及一个完整的退避示例,因为没有标准能为他们填补这些空白。
- 通过 SDK 调用的用户 - 你的 SDK 就是 他们的重试循环。它的默认设置(退避、抖动、尝试次数上限、自动生成键)构成了大多数调用者不会深入阅读的契约,因此错误的默认设置会在整个集群范围内造成重复操作。
优先为原始 HTTP 集成方设计——足够完整的契约也正是你自己的 SDK 随后会自动化实现的内容——并将 SDK 默认设置视为影响力最大的产物,因为它们决定了绝大多数从不阅读文档的用户的行为。
工作流
- 找出重试不安全的端点。
- 设计键契约。
- 设置存储和 TTL。
- 原子性地声明键。
- 设计响应语义。
- 编写客户端重试指南并设置 SDK 默认值。
- 为集成方记录契约。
下面按顺序逐节介绍每个步骤。
1. 找出重试不安全的端点
- RFC 9110 是此处唯一相关的正式标准文档,它将 GET、PUT 和 DELETE 定义为幂等方法,将 POST 定义为非幂等方法。它没有涉及
Idempotency-Key改造模式。 - 列出所有会更改状态的端点。每个端点都必须接受幂等键,或明确记录为重试不安全——这是
samber/developer-platform-skills@public-api-design-review在审查级别执行的检查;本技能旨在实现前一种选择的前提。 - 验证声称的幂等性,不要轻信 HTTP 方法:会向日志追加内容的 PUT,或会递减计数器的 DELETE,无论方法如何承诺,都不是幂等的。
2. 设计关键契约
- 由客户端生成密钥,服务端绝不推导密钥。AWS 的 Builders' Library 提出了不采用的替代方案:在服务端对请求参数进行哈希,在两个实际相同的请求都出于本意需要执行时(例如有意启动两个相同的实例)就会失效——调用方提供的密钥能够表达意图,而推导出的密钥无法表达。Stripe、AWS、Twilio 和 Resend 都将控制权交给客户端。
- 按调用方限定密钥范围——
(account_id, idempotency_key)——绝不全局限定。Stripe、AWS 和标准 Postgres 参考实现都以这对值作为键,因此不同账户重复使用相同的密钥字符串也不会冲突。 - 向客户端推荐一种生成方式。三种方式都可行;应为客户端排出优先级,而不是并列罗列(Resend 自己的推荐顺序):
- 价值:
event-derived > request-scoped > random UUID reused across retries - 工作量:
event-derived > request-scoped > random UUID reused across retries - 效率:
event-derived > request-scoped > random UUID reused across retries——价值和工作量的排序完全相同,因此两者的差距决定了这个比率:工作量只需几分钟(将密钥命名为调用方已有的标识符),而价值关乎整个保障。 - 默认档位:基于事件生成(
order-confirm-${orderId})——构造上是确定性的,因此重试不会忘记复用密钥。如果调用时没有稳定的业务标识符,则降至基于请求生成;如果调用方是无状态脚本,则降至复用随机 UUID。这里没有任何档位会被忽视:每个档位都只需一行客户端代码。 - 绝不要在这些档位之下再排出第四档:每次尝试都生成新值(
Date.now()、重试时重新生成randomUUID())会彻底破坏整个机制,因此应将其记录为反模式。
- 价值:
- 校验请求载荷:相同密钥对应不同请求体属于客户端错误——应拒绝请求(409 是业界惯例;Resend 将其命名为
409 invalid_idempotent_request),绝不要静默重放。已过期的 IETF 草案将其概括为可选的请求载荷_指纹_,并与密钥一同存储;最简单的形式是请求载荷的校验和。 - 存储两种结果。Stripe 会保存失败和成功结果——包括 500 错误——因此重试会重放完全相同的原始结果。应明确决定存储的失败是否原样重放(Stripe v1),还是允许在不产生副作用的情况下重新尝试操作(Stripe v2);不作决定就是设计缺陷。
3. 设置存储与 TTL
- 保留期涉及两个独立的问题,而非一个 TTL(Brandur Leach 的参考实现):
- 重放窗口(约 24h)——决定幂等重放的正确性;Stripe、Resend 和 Twilio 也都采用这一惯例并记录在文档中。
- 清理阈值(约 72h)——更长的运维窗口,可让记录保留,以便人工恢复;这样,周五糟糕的部署所导致的失败请求会保留到周末,等待周一修复。
- 根据最长重试链(问题 6)设置重放窗口,绝不能根据存储成本来设置。7 天的 DLQ 重放若只有 24 小时的 TTL,注定会发生重复请求。AWS 会将 EC2 运行令牌保留到资源生命周期结束后再加上一段宽限期——正是因此,即使资源已删除,较晚到达的重试也能得到一致的答复。
- 过期后,将复用的密钥视为全新请求——所有受调查的提供方都如此处理;没有一家会对 TTL 过期后的复用返回错误。
- 原样存储完整响应(状态码、重要的标头、正文),而非摘要——重放结果必须与原始结果无法区分。
4. 原子地认领键
在并发重试时,先检查再插入会产生 TOCTOU 竞态:两个调用方都看到“缺失”,于是都继续执行。认领操作必须是由存储层仲裁的单个原子操作。生产环境中常见三种机制,排序如下:
-
效率:
DB unique constraint > conditional-write OCC > row lock + SERIALIZABLE -
工作量:
row lock + SERIALIZABLE > conditional-write OCC > DB unique constraint -
价值:
row lock + SERIALIZABLE > DB unique constraint == conditional-write OCC -
默认选项:对
(account_id, idempotency_key)设置唯一约束。 数据库选出唯一的胜者;落败者直接因INSERT失败而得到 409,而不是通过应用层检查得到。几乎不费成本——只需一个索引——而且事务隔离无需额外成本,就能提供下文的防护保证。 -
条件写入 OCC(DynamoDB 风格) 可在不支持唯一约束事务的存储上实现同样的单一胜者认领——价值一栏中的并列确实成立:保证相同,存储类型不同。问题 3 的答案会排除这两个选项中的一个,而不是对它们进行排序;被排除的选项应直接删除,而不是降级。
-
行锁 + SERIALIZABLE 事务是最不受青睐的选项。
- 价值:SERIALIZABLE 事务中的
locked_at列将_进行中_作为一种一等状态来表示,即使锁过期也能保留,并可作为可恢复检查点的锚点。 - 工作量:需要调整隔离级别并处理序列化中止后的重试——因此在效率方面的比较中,它每次都落败。
- 当请求涉及外部状态变更时(问题 4),应提升其优先级:多步骤请求需要更丰富的认领生命周期,而不只是选出胜者。
- 价值:SERIALIZABLE 事务中的
-
无论采用哪种机制:认领操作和业务变更必须作为一个 ACID 单元提交——AWS 将其视为硬性要求,而非最佳实践。在受其保护的事务之外进行认领,可能会为从未创建的资源记录令牌,反之亦然。
-
仅使用 Redis
SETNX+ TTL 锁并不是第四种选项。Kleppmann 的分析指出:如果没有 fencing token,客户端暂停时间超过租约期限后,仍可能在锁转移后写入——任何时序假设都无法解决这个问题。受调查的供应商中,没有一家使用分布式锁进行认领;如果你只有 Redis,就在其上叠加 fencing token,并且要知道,DB 约束可以完全避开这个问题。 -
此排序是默认方案,而非铁律。应根据存储方案和工作量上限(问题 3 和 7)重新排序:已经运行 SERIALIZABLE 工作负载的团队,实现这个最不受青睐的选项所需的成本会远低于工作量排序的假设。如果上线日期不允许进行架构迁移,就优先采用当前存储已经支持的机制。
5. 设计响应语义
大多数自制实现的问题在于把键当成二元状态——已见或未见。键实际上是一个三态机:不存在、处理中、已完成。重试命中处理中的键并不是问题(“连接断开时,你的客户端库只是按配置正常工作”);问题在于服务端没有办法表示“已收到,但尚未完成”。
| 结果 | 发生的情况 | 正确行为 |
|---|---|---|
| 从未收到 | 处理开始前网络断开 | 重试作为首次尝试继续;正常赢得声明 |
| 已收到,尚未处理 | 声明已持有,尚无最终结果 | 返回处理中冲突,或在原锁过期后从上一个恢复点继续 |
| 已处理,响应丢失 | 结果已提交;响应始终未到达 | 原样重放已存储的响应——不得重新执行 |
-
对于处理中的情况,选择以下一种方式:
- 返回 409(Resend 的
409 concurrent_idempotent_requests,文档建议“等待后重试”)——最简单、最安全的默认做法。 - 在调用方需要同步获取结果时,阻塞直到结果确定。
- 对于长时间运行的效果,返回 202 和状态 URL。
绝不要因为第一个调用“似乎卡住了”就让第二个调用继续。已过期的 IETF 草案刻意让处理中的冲突与已完成请求的重放返回_不同_响应,而不是互为回退方案。
- 返回 409(Resend 的
-
对于多步请求(问题 4),将其拆分为若干原子阶段,每次外部状态发生变更后就划分一个阶段,并在阶段之间持久化恢复点——重试的请求会从准确的中断位置继续,而不会重新执行已经发出的外部调用。在发起外部调用_之前_记录意图,这样即使在调用和收到响应之间发生崩溃,也会留下证据。
-
按 Brandur 的模型,将恢复作为主动后台任务运行,而不是被动等待过期:
- _完成器_会推动已被放弃的处理中请求直至完成。
- _清理器_会删除过期键,并记录无法完成的任务,供人工处理。
-
错误响应的形式——代码、封装格式、
retryable标志、Retry-After——属于samber/developer-platform-skills@api-error-design的范畴;使用其信号,不要在这里重新设计。用不同的机器可读代码区分两种 409 含义(载荷不匹配与处理中),就是将该技能的分类法应用于本技能的状态。
完整的生命周期示例(包括架构、声明查询、三种状态的响应,以及多步恢复点示例),请参阅 references/key-lifecycle-walkthrough.md。
6. 编写客户端重试指南并设置 SDK 默认值
- 只重试明确列出的情况,绝不笼统捕获所有异常:429(遵循
Retry-After)、502、503、504、连接超时和连接重置;对 500 谨慎处理(有些 500 是真实 bug,重试结果也会完全相同)。绝不重试非 429 的 4xx、身份验证失败或由 bug 引发的异常。四个独立来源(tenacity、Resilience4j、Resend 和响应信号侧)都得出了完全一致的划分——应将其作为趋同的实践引用,而非某一家供应商的观点。 - 对非幂等操作,如果尚未设置幂等键,绝不重试读取超时。这正是本技能的服务端部分要解决的边界条件。
- 退避必须加入抖动。优先级排序(Marc Brooker 的 AWS 分析是权威来源):
- 价值:
full jitter > decorrelated jitter > equal jitter > no jitter - 工作量:
full jitter == decorrelated jitter == equal jitter == no jitter——这是真正的并列,并非敷衍:每种方式都只需一个公式或一个库标志,实现、测试和逆向分析的成本完全相同,因此没有哪一级需要读者投入更多时间才能获得其价值。 - 效率:价值排序不变,因为工作量相同,不影响比值。
- 默认级别:full jitter(
sleep = random(0, min(cap, base * 2^attempt)))——客户端总工作量和服务端负载最低,但总完成时间会略长。 - 该排序中次优的是 decorrelated jitter:总工作量更多,但完成速度更快。当面向用户的调用处于严格的截止时间内,且成功所需时间比全局负载更重要时,可将其提升为首选。Brooker 认为 equal jitter 在带抖动的选项中「明显最差」;no jitter 则会让整个集群重新同步,形成导致故障的同一波峰值——不要将这两种方案列入提供给集成方的选项。
- 应根据对调用方的了解重新排序:单实例批处理任务无需承担此排序所假设的集群同步成本。
- 价值:
- 不仅要限制尝试次数,还要限制总时长——每次尝试都在合理范围内的循环,累计起来仍可能超出调用方自己的预算。记录每次重试(尝试次数、错误、下一次等待时长);静默重试循环会掩盖正在发生的故障。
- 增加集群范围的重试预算,与每个调用方的限制区分开:在滑动窗口内,重试次数超过流量的指定比例后就停止重试。一百个实例各自重试三次,会将负载放大 300 倍,而每个实例单独看来都合情合理。AWS 的令牌桶是这种机制的典型实现,并针对真实故障优化了配额触发速度:瞬态错误重试消耗 14 个令牌,限流重试消耗 5 个令牌。
- 传递截止时间:在最上层设置一个总截止时间,在其中分配每次尝试的超时时长——绝不为每次尝试重新设置总超时时间,否则会悄然延长调用方的实际等待时间。将单次超时设置为实测 p99 的 2-3 倍,而非采用操作系统默认值。gRPC 的线上相对超时模型可作为调用链机制的引用。
- 先向集成方推荐其所用语言的重试库(tenacity、Resilience4j 或同类库),再介绍公式——库中的公式只需一行,且已过调试。与熔断器配合时,应说明叠加顺序:
Retry → CircuitBreaker → RateLimiter → Bulkhead。 - 对于你发布的 SDK:默认开启重试,并使用退避 + 抖动,且限制尝试次数(AWS 默认总共尝试 3 次);对于 SDK 能处理的不安全操作,自动生成幂等键。默认值就是从不读文档的调用方所依赖的契约。重试默认值与平台限流策略的交互方式——例如 SDK 重试是否计入配额、自适应限流——属于
samber/developer-platform-skills@api-rate-limit-policy的范畴。
公式、预算机制和截止时间计算:references/backoff-jitter-formulas.md。用于校准自身设置的跨供应商默认值:references/vendor-sdk-retry-defaults.md。
7. 为集成方记录契约
由于没有标准定义这些语义,你的文档是集成方唯一的规范性信息来源。根据上述受众划分,记录以下内容:
- 哪些端点接受密钥、标头名称、密钥最大长度,以及建议的派生方式。
- 作用域、以具体数字表示的重放窗口,以及窗口过期后的处理方式(“视为新请求”)。
- 两种冲突行为及各自不同的错误代码:负载不匹配和请求正在处理时出现的重复请求。
- 已存储的失败是会重放还是会重新尝试(将 Stripe v1/v2 的区别应用到你的 API)。
- 提供包含具体数字的重试循环示例——间隔计划、上限、抖动——例如 Resend 文档中所述的
1s → 2s → 4s → 8s,上限为 30s。 - 绝不要写“精确一次投递”——在开放网络中,这一说法可以被证明是错误的。应改为陈述真实情况:在文档说明的密钥作用域和时间窗口内,重试是安全的。若营销材料中已经作出此类声明,也要予以更正(Treat 的最有力论证:Kafka 的精确一次语义仅在封闭的事务系统内成立;公共 API 从定义上就不属于此类系统)。
失败模式
反模式检查清单——每一项都是直接的审计发现:
- 接受密钥,却从不存储或使用密钥——这会让客户端误以为重试是安全的,而事实并非如此;这比完全不接受密钥更糟。
- 每次尝试都生成一个新的密钥值。
- 使用先检查后插入的方式声明密钥(TOCTOU 竞态)。
- 密钥状态只有二元表示,没有“正在处理”状态。
- 同一密钥对应不同负载,却被静默重放。
- 重放窗口短于最长重试链(24h TTL 短于 7 天 DLQ 的陷阱)。
- 使用没有 fencing token 的裸
SETNX锁来声明密钥。 - 未决定已存储的失败是重放还是重新执行。
- 每次尝试都会重置整体超时的重试循环。
- 退避策略没有抖动。
- 文档中任何地方出现“精确一次”。
衡量指标
- 并发门槛:针对会改变状态的端点,使用同一个密钥并发发送 N 个相同请求——必须恰好执行一次副作用;其余 N-1 个请求收到“请求正在处理”的冲突响应或已存储的重放响应。通过或失败一目了然;不断迭代密钥声明设计,直到满足此条件。这是定义性保证,可直接测试。
- 崩溃恢复门槛:在多步请求的原子阶段之间终止服务端,然后重试——请求必须从恢复点继续,且不得重复调用任何外部服务。对于问题 4 的答案为“是”的情况,通过或失败一目了然。
- 生产环境中的副作用重复率:根据定义,在文档说明的作用域和时间窗口内,设计目标就是零——将其作为门槛退化的告警指标跟踪,而非可调阈值。
- 孤立密钥率(已声明但从未完成——完成器的队列):目前没有已发布的行业阈值,因此以自有的第一个月数据为基线,并像监控其他未经来源证实的运营指标一样关注趋势。
调用示例
- “为我们的 POST /payments 和 POST /orders 端点添加 Idempotency-Key 支持——移动客户端因超时重试时,我们发现会重复扣款。”
- “编写 API 文档中的重试指南部分,并为我们发布的 Python 和 Node SDK 选择默认退避参数。”
- “两个并发的 webhook 处理程序都创建了相同的发票——审计我们的幂等性实现并找出竞态。”
参考资料
- references/key-lifecycle-walkthrough.md - 密钥生命周期详解:架构、原子声明、包含真实 409 错误代码的三状态响应、恢复点、完成器/清理器。
- references/backoff-jitter-formulas.md - 三种抖动公式及其权衡、重试预算机制、截止时间传播、超时设置。
- references/vendor-sdk-retry-defaults.md - AWS、Stripe、Google Cloud、Twilio 和 Resend 的默认重试与幂等行为;哪些做法值得借鉴,哪些需要标注。