分析阅读约 2 分钟

代理重试:丢失一个响应,创建了两个任务

本地代理在 POST 创建任务后丢弃了响应。了解重试何时会产生重复任务、幂等键何时才真正有用,以及何时应改用状态查询与对账流程。

本页内容

在代理故障后重试 POST 之前,先确定目标站点可能已经做了什么。丢失响应意味着这次操作的结果不确定。即使第一个任务已经存在,第二个请求仍可能再创建一个任务。

在我们的本地演示中,客户端在源站创建任务之后收到了错误。重复发送 POST 创建了第二个任务。当接收端忽略键时,带同一个键重复发送同样创建了第二个任务。只有接收端实际实现的去重约定,才让同键重放返回原始任务。

这些是内存中的合成任务记录,而不是已完成的生产任务。有用的结果在于「尝试的请求数」与「观察到的应用效果数」之间的差异。你可以在下载包中复现这两者。

在创建任务之后丢失响应

下载并解压代理重试演示。在解压后的目录中运行:

sh
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。任何重复请求或查询都是客户端随后的显式动作。

捕获到的顺序如下:

code
origin: job applied
origin: response sent
proxy: complete origin response received
proxy: response dropped before client headers
client: request failed

源站自身的记录生成了下面这张矩阵。POST 计数包含最初的尝试;查询是单独的 GET。

响应丢失后的动作收到的 POST收到的 GET创建的任务客户端下一次结果
在没有重放约定的情况下重复 POST202201,第二个任务
重用一个接收端忽略的键202201,第二个任务
重用受支持的键且输入不变201201,原始保存结果
为重试生成新键202201,第二个任务
重用受支持的键但输入已更改201409,输入冲突
查询已确认的操作;不发送第二个 POST111200,找到现有任务

因此在这个夹具中,第二次尝试的 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 幂等功能。

参考来源

  1. RFC 9110: idempotency and retry semantics
  2. AWS Builders Library: making retries safe with idempotent APIs
  3. Stripe: idempotent requests
  4. HTTPX: transport retries
  5. HTTPX 0.28.1: transport implementation
  6. Python: asyncio.timeout

标签:ProxiesTroubleshooting