在代理故障后重试 POST 之前,先确定目标站点可能已经做了什么。丢失响应意味着这次操作的结果不确定。即使第一个任务已经存在,第二个请求仍可能再创建一个任务。
在我们的本地演示中,客户端在源站创建任务之后收到了错误。重复发送 POST 创建了第二个任务。当接收端忽略键时,带同一个键重复发送同样创建了第二个任务。只有接收端实际实现的去重约定,才让同键重放返回原始任务。
这些是内存中的合成任务记录,而不是已完成的生产任务。有用的结果在于「尝试的请求数」与「观察到的应用效果数」之间的差异。你可以在下载包中复现这两者。
在创建任务之后丢失响应
下载并解压代理重试演示。在解压后的目录中运行:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
python retry_jobs_demo.py > result.json
python retry_jobs_demo.py --csv result.json该示例需要 Python 3.11 或更高版本,并固定使用 HTTPX 0.28.1 和 HTTPcore 1.0.9。它使用 Python 3.11 新增的 asyncio.timeout。安装依赖会访问包索引。演示本身在回环地址上启动一个临时的 HTTP 源站和正向代理;不涉及任何供应商账号或外部目标站点。
在六个独立案例中的每一个里,源站都会应用第一个 POST 并发送其响应。代理读取完整的响应,然后故意关闭下游连接,不向客户端发送响应头。在这次固定版本的运行中,客户端记录到 RemoteProtocolError。任何重复请求或查询都是客户端随后的显式动作。
捕获到的顺序如下:
origin: job applied
origin: response sent
proxy: complete origin response received
proxy: response dropped before client headers
client: request failed源站自身的记录生成了下面这张矩阵。POST 计数包含最初的尝试;查询是单独的 GET。
| 响应丢失后的动作 | 收到的 POST | 收到的 GET | 创建的任务 | 客户端下一次结果 |
|---|---|---|---|---|
| 在没有重放约定的情况下重复 POST | 2 | 0 | 2 | 201,第二个任务 |
| 重用一个接收端忽略的键 | 2 | 0 | 2 | 201,第二个任务 |
| 重用受支持的键且输入不变 | 2 | 0 | 1 | 201,原始保存结果 |
| 为重试生成新键 | 2 | 0 | 2 | 201,第二个任务 |
| 重用受支持的键但输入已更改 | 2 | 0 | 1 | 409,输入冲突 |
| 查询已确认的操作;不发送第二个 POST | 1 | 1 | 1 | 200,找到现有任务 |
因此在这个夹具中,第二次尝试的 201 意味着两种不同的情况:新创建的第二个任务,或第一个任务的保存结果。我们通过检查任务标识和源站的效果计数来区分它们。幂等性关注的是重复某个操作的预期效果;仅凭状态码无法证明它。HTTP 幂等性语义。
重要的边界在于「应用操作」与「交付其响应」之间。这与在应用请求发出之前就失败的连接尝试不同。对于 HTTPS 目标站点,建立 CONNECT 隧道时的失败,与通过已建立隧道发送 POST 后丢失响应,也发生在不同的阶段。超时指南解释了这些阶段。
本演示中的代理不会重试请求。第二个 POST 是客户端的显式动作。这一区别很重要:HTTP 语义禁止代理自动重试非幂等请求。客户端也需要有把非幂等操作视为可重复的依据,或者有原始请求未被应用的证据。RFC 9110 第 9.2.2 节。
根据操作的状态选择下一步动作
异常类描述的是客户端的观察结果。它不是应用层的回执。请依据关于逻辑操作(即调用方想要创建的那个任务)的证据来决定接下来做什么。
| 可用的证据 | 你知道什么 | 下一步动作 |
|---|---|---|
| 权威结果标识出了已创建的任务 | 创建已生效 | 使用或对账该结果;不要再次创建任务 |
| 结果未知,但接收端支持用你保留的操作键和不变的输入进行重放 | 其文档化的约定可能允许你重复尝试而不重复效果 | 在接收端的作用域和保留规则内重用该标识 |
| 可靠证据表明没有任何尝试被应用,也不可能再被应用 | 逻辑操作尚未生效 | 可以在正常的请求预算内考虑新的尝试 |
| 结果未知,且不存在适用的重放约定 | 重复 POST 可能产生第二次效果 | 使用该服务的状态/对账流程,或将未解决的操作上报 |
对于查询案例,调用方保留了 X-Operation-Ref: operation-1;夹具的 GET /operations/operation-1 返回关联的任务。该引用在 POST 之前就已知,因此查询不需要来自丢失响应的任务 ID。
要谨慎对待空结果或失败的查询。某一时刻的「未找到」并不能证明更早的请求不会在之后到达或完成。演示中的正向查询之所以有效,是因为它返回了已经创建的任务。它没有测试负向查询的安全性。这一区别源自 AWS Builders Library 中讨论的迟到请求问题。
退避延迟改变的是你再次尝试的时间。它无法确定第一次操作是否已经发生。502、504、超时或断开的响应仍然需要结合其请求阶段和应用上下文来判断。请使用代理状态码文章来识别响应来自哪一层;不要把其中的错误分类变成一份通用的「可安全重试 POST」清单。
请求头需要一个能理解它的接收应用
受支持键的夹具在进程内锁的保护下记录任务及其响应。在其单调用方的作用域内,键绑定到 POST 端点,并与操作引用和解析后的载荷进行比较。匹配的重复请求会收到保存的任务结果。输入已更改的请求会收到夹具明确的 409 响应,而不会创建另一个任务。
忽略键的案例改变的是接收应用的行为,而不是请求头的写法。新键的案例改变的是呈现给应用的操作标识。两者都创建了两个任务。当约定允许重放时,请重用预期操作的标识;每次尝试都生成新键会破坏这种关联。
该夹具只在其生命周期内将记录保存在内存中。它的锁演示的是单个进程内的协调。它没有让任务创建持久化、没有覆盖重启、没有安全地让键过期,也没有协调发送消息之类的外部效果。这些特性需要超出本示例的应用设计。
真实的 API 会定义自己的约定。例如,Stripe 的文档说明会存储请求结果以便用同一个键重用、会检查后续参数,并在存储的记录被移除后把重用的键当作新请求处理。这些是 Stripe 的规则;向另一个端点发送名称相似的请求头并不会带来这些规则。Stripe 幂等请求。
在生产环境依赖某个键之前,请与接收服务确认四件事:
- **标识与作用域:**该键标识的是哪个调用方、账号、端点和操作?
- **输入与并发:**输入更改和重叠的尝试如何处理?
- **保留与恢复:**保护持续多久,重启或部分故障后哪些内容得以保留?
- **结果语义:**对于已存在、进行中、失败或已完成的操作会返回什么?
AWS 的设计讨论把请求标识与变更的原子记录联系起来,并说明了为何迟到请求和意图更改需要显式处理。它是这些问题的有用背景,而不是每个 API 都实现相同保证的证据。用幂等 API 让重试更安全。
将客户端重试与业务结果分开
不要把客户端的成功响应计数当作任务数。将逻辑操作标识与每次传输尝试分开记录。当响应丢失时,在服务的约定或权威状态支持更强的结论之前,保留「结果未解决」的状态。这样在你的调查中,恢复的响应、重放的结果和新创建的任务就能相互区分。
对库的设置也要精确。HTTPX 的文档说明其内置传输重试只针对连接错误和连接超时,而不是任意的读/写失败或状态码。在固定版本的 HTTPX 0.28.1 实现中,HTTP 代理分支不会把传输层的 retries 设置传入其代理连接池。本实验使用显式的客户端动作和默认为零的重试;它不依赖该选项来重放经代理的 POST。HTTPX 传输文档、标记版本的传输源码。
在监控方面,请分别统计尝试次数、观察到的响应失败、已确认的任务和未解决的逻辑操作。基准测试方法文章解释了为什么以尝试次数作分母很重要。对于会改变状态的集成,在宣布恢复成功之前要加入应用层的结果。
下载与测试边界
压缩包包含完整的演示、测试、版本固定、README、记录的 JSON 及其派生的 CSV。你也可以分别查看代码、测试、依赖要求、README、事件输出和结果矩阵。
ipvolt 于 2026 年 9 月 13 日在 macOS arm64 上使用 CPython 3.14.7 和上述固定版本的包运行了本地检查。检查涵盖六案例矩阵、重叠的同键请求、输入更改冲突、不同的标识、拒绝转发到外部目标站点以及夹具清理。记录的案例结束时客户端已关闭,没有残留的夹具处理程序或写入器。
测试使用 HTTP/1.1 转发。它没有测试 CONNECT、TLS、身份验证、SOCKS、外部供应商、持久存储、键过期或远程服务的迟到请求行为。它不对供应商可靠性或「恰好一次」处理做任何声明。
如果你想在 ipvolt 开放访问时收到通知,请加入早期访问名单。访问开放时发送一封邮件。仅此而已。本演示并不描述任何可用的 ipvolt API 或 ipvolt 幂等功能。