mcpField
A
api-idempotency-retry

api-idempotency-retry

github.com/samber开发78.7k 次安装3 天前更新
代码仓库

关于 api-idempotency-retry

api-idempotency-retry 是面向 API 团队的设计技能,用于共同制定服务端幂等键和客户端重试行为。它涵盖键契约、原子占用、响应重放、保留期限与 SDK 默认值,避免只实现重试或幂等的一端。

主要功能

  1. 01识别重试不安全的端点
  2. 02定义键范围与请求体匹配
  3. 03设置重放和保留期限
  4. 04原子化占用幂等键
  5. 05规定处理中与完成后的响应
  6. 06编写重试指南和 SDK 默认值

适用场景

  1. 01防止连接中断后重复产生操作
  2. 02为第三方 HTTP 集成方设计重试
  3. 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.json
references/
backoff-jitter-formulas.md
key-lifecycle-walkthrough.md
vendor-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;如果使用其他记忆系统,则以该系统为准。该文件仅供参考,并非强制要求。将不同任务的信息分开存放。已完成的任务应移除。为任务添加日期;一般项目背景信息则不添加日期。部分访谈回答可能因任务而异。

澄清问题

设计任何内容之前,请先询问以下问题;每个答案都会影响后续步骤。将问题打包一起询问——这是战术性设计任务,不是战略访谈。

  1. 范式:REST、gRPC,还是两者都有?(gRPC 原生支持截止时间传播;REST 则需要标头约定和显式的截止时间传递机制)
  2. 哪些端点不是幂等的?请提供端点列表或 OpenAPI 规范,并列出所有会更改状态的操作。
  3. 幂等键存储使用什么后端——带唯一约束和事务的关系型数据库、支持条件写入的 KV 存储,还是仅有缓存?(这会决定哪些原子认领方案可用——参见步骤 4)
  4. 是否有请求会在执行过程中调用不可回滚的外部系统(例如扣款、发送外发电子邮件)?(这会影响恢复点——参见步骤 5)
  5. 你会发布哪些语言的 SDK,集成方是否也会直接调用原始 HTTP?(这会决定你能为他们预设多少重试机制——参见步骤 6)
  6. 最长的重试链可能有多长——死信队列重放窗口、争议处理窗口、计划重新运行?(这会影响 TTL——参见步骤 3)
  7. 保障最迟需要何时生效:是一次性修复(阻止某个正在提交工单的客户端重复扣款),还是一项会由今后每个端点继承的持续性约定?认领机制的工作量上限是多少——例如,交付日期是否排除了架构或隔离级别迁移,还是团队已经熟悉 SERIALIZABLE 工作负载?(这会重新排列认领方案——参见步骤 4:硬性期限会优先考虑当前存储已有支持的方案;持续性要求会优先考虑更完善的认领生命周期;如果上限不允许进行架构迁移,就直接排除无法实现的方案,而不是将它们排在最后)

谁控制重试循环

每次重试决策都由开发者或程序做出,无论谁购买了产品。设计取决于谁控制重试循环:

  • 一方开发者 - 你控制两端;内部约定加上代码审查就能解决大部分问题。这是服务成本最低的受众。
  • 通过原始 HTTP 调用的第三方集成方 - 他们仅凭你的文档编写自己的重试循环。他们需要完整公开的契约:键语义、TTL、不匹配和请求处理中行为,以及一个完整的退避示例,因为没有标准能为他们填补这些空白。
  • 通过 SDK 调用的用户 - 你的 SDK 就是 他们的重试循环。它的默认设置(退避、抖动、尝试次数上限、自动生成键)构成了大多数调用者不会深入阅读的契约,因此错误的默认设置会在整个集群范围内造成重复操作。

优先为原始 HTTP 集成方设计——足够完整的契约也正是你自己的 SDK 随后会自动化实现的内容——并将 SDK 默认设置视为影响力最大的产物,因为它们决定了绝大多数从不阅读文档的用户的行为。

工作流

  1. 找出重试不安全的端点。
  2. 设计键契约。
  3. 设置存储和 TTL。
  4. 原子性地声明键。
  5. 设计响应语义。
  6. 编写客户端重试指南并设置 SDK 默认值。
  7. 为集成方记录契约。

下面按顺序逐节介绍每个步骤。

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),应提升其优先级:多步骤请求需要更丰富的认领生命周期,而不只是选出胜者。
  • 无论采用哪种机制:认领操作和业务变更必须作为一个 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 草案刻意让处理中的冲突与已完成请求的重放返回_不同_响应,而不是互为回退方案。

  • 对于多步请求(问题 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 处理程序都创建了相同的发票——审计我们的幂等性实现并找出竞态。”

参考资料

评论

支持 Markdown