bigywater.tech
爬虫工程 · 33

POST 重试:幂等性与重复提交

重试 GET 几乎无害,重试 POST 却可能重复下单。这篇用一个会“创建订单后丢掉回应”的本机服务器,把 urllib3 的 Retry 对不同方法的默认行为、POST 重试的后果、以及 Idempotency-Key 的作用都量一遍。

2 个订单
对 POST 打开重试后,“创建订单后丢掉回应”的场景最终创建的订单数(默认不重试是 1,带 Idempotency-Key 是 1)
6 个
Retry 默认允许重试的方法数:DELETE、GET、HEAD、OPTIONS、PUT、TRACE
503 之前
服务器可能已经把订单创建好了——5xx 不代表什么都没做

哪些方法会被重试

实测 · 本机执行输出
  Retry.DEFAULT_ALLOWED_METHODS = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE']
  Retry(total=3, status_forcelist=[503]) 对 GET    → 状态码 503 时是否重试:True
  Retry(total=3, status_forcelist=[503]) 对 HEAD   → 状态码 503 时是否重试:True
  Retry(total=3, status_forcelist=[503]) 对 PUT    → 状态码 503 时是否重试:True
  Retry(total=3, status_forcelist=[503]) 对 DELETE → 状态码 503 时是否重试:True
  Retry(total=3, status_forcelist=[503]) 对 OPTIONS→ 状态码 503 时是否重试:True
  Retry(total=3, status_forcelist=[503]) 对 TRACE  → 状态码 503 时是否重试:True
  Retry(total=3, status_forcelist=[503]) 对 POST   → 状态码 503 时是否重试:False
  Retry(total=3, status_forcelist=[503]) 对 PATCH  → 状态码 503 时是否重试:False
  allowed_methods=None(对所有方法都重试):POST → True

Retry.DEFAULT_ALLOWED_METHODS 只有 DELETE、GET、HEAD、OPTIONS、PUT、TRACE(按 HTTP 语义是幂等的)。POST 和 PATCH 默认不会因为状态码或错误被重试。

重试 POST 的后果

实测 · 本机执行输出
  默认 Retry(total=3)(POST 不在 allowed_methods 里):客户端结果 ConnectionError;服务器收到 1 次 POST,实际创建了 1 个订单
  Retry(total=3, allowed_methods=None)(对所有方法重试):客户端结果 200(订单号 2);服务器收到 2 次 POST,实际创建了 2 个订单
  allowed_methods=None + 请求带 Idempotency-Key:客户端结果 200(订单号 1);服务器收到 2 次 POST,实际创建了 1 个订单

如果你把 allowed_methods=None 打开,在“订单已创建、回应丢了”时,重试会再创建一个订单(2 个)。让重复安全需要服务器配合:请求里带 Idempotency-Key,服务器按它去重(这里我写的服务器做了),重试时只剩 1 个订单。客户端单方面加头没有用。

5xx 不等于没做

实测 · 本机执行输出
  Retry(total=3, status_forcelist=[503])(POST 默认不重试):客户端结果 503(订单号 busy);服务器收到 1 次 POST,创建了 1 个订单
  allowed_methods=None, status_forcelist=[503]:客户端结果 200(订单号 2);服务器收到 2 次 POST,创建了 2 个订单
  同上 + Idempotency-Key:客户端结果 200(订单号 1);服务器收到 2 次 POST,创建了 1 个订单

先创建订单再回 503,客户端看到的是失败,但副作用已经发生。重试 POST 时必须假设“上一次可能已经生效”。

幂等方法

实测 · 本机执行输出
  PUT 被重置一次后默认 Retry 自动重试:结果 200,服务器收到 2 次,资源值 b'v=1'
  GET 被重置一次后默认 Retry 自动重试:结果 200,服务器收到 2 次,资源值 None

PUT 到固定资源,重试后资源值不变;GET 无副作用。这两类自动重试是安全的(前提是服务器真的按语义实现)。

分项上限

实测 · 本机执行输出
  Retry(total=3):结果 200,服务器收到 2 次
  Retry(total=3, read=0)(读取阶段不重试):结果 ConnectionError,服务器收到 1 次
  Retry(total=3, read=1):结果 200,服务器收到 2 次
  Retry(total=0):结果 ConnectionError,服务器收到 1 次

Retry 除了 total,还有 connect、read、status 等分项;读取阶段出错的 GET,read=0 时不重试,默认会重试。

分步演示

POST 重试
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。“下单”服务器:POST /order 创建一个订单(记录在内存列表里),按 Idempotency-Key 去重(如果请求带了);可配置在创建订单后丢掉回应(SO_LINGER=0 重置连接)或先回 503;客户端 requests 2.34.2 的 Session 挂 HTTPAdapter(max_retries=Retry(…)),urllib3 2.7.0;订单数以服务器列表长度为准
    • 没有验证:真实支付/下单 API 的 Idempotency-Key 语义和有效期;GET 请求带副作用的网站(爬虫遇到的“删除”链接用 GET 就会被重试);redirect 307/308 对 POST 的重放(第 6 篇测了方法与请求体);aiohttp 或 httpx 里没有内置重试时自己写重试循环的类似问题;事务性补偿的做法
    京ICP备2026067502号-1京公网安备11010502063487号