HTTP 幂等关注的是重复同一个请求时,服务器上预期发生的效果,而不是每次响应是否一模一样。服务器可能已经执行修改,却在客户端收到答案之前断开连接;这时重复请求可能合理,但前提是操作的真实语义允许重复,并且客户端仍遵守有界重试政策。不能把“可以重复”扩大成所有失败都应该立即重试。
相同效果,不代表相同响应
RFC 9110 用多次相同请求与单次请求的预期服务端效果来定义幂等。PUT、DELETE 和安全方法都属于幂等方法。第一次成功 PUT 可能创建资源并返回 201,再次发送则可能替换已有资源并返回 204;先删除存在的资源,再删除已经不存在的资源,也可以返回不同状态码,但最终都让目标保持不存在。
“安全”是另一种性质:客户端没有请求改变状态。GET、HEAD、OPTIONS、TRACE 被定义为安全方法,但允许附带访问日志等行为,因此不是说服务器任何地方都绝对不能写入。PUT 和 DELETE 可以符合幂等语义,却不是安全方法;把两个概念混用,会影响自动抓取与重试策略。
方法名称需要实现来兑现
把一个每次递增的接口命名为 PUT,不会让重复递增自动变成幂等。服务器需要真正兑现公开的方法语义。同样,用 GET 删除指定对象违反安全方法的约定,预取器或爬虫可能在没有删除意图时触发它。不能因为请求行上写着某个词,就跳过对业务状态变化的检查。
POST 通常不保证幂等,但某个具体 POST 操作可以通过应用层契约去重;它的用途也不局限于创建资源。自动重放之前需要理解实际契约,不能断言所有 POST 都不能重试,也不能反过来认定所有使用幂等方法名称的接口都已经正确实现。
天然可重复的写入与记住操作键,解决的是相关但不同的问题。用指定表示替换资源,是表达希望得到的状态;凭借幂等键重放订单创建,则是在识别某次特定行动。两者都不意味着每一个内部函数、访问日志或辅助步骤只会执行一次。
前置条件处理的是竞争修改
幂等本身不能阻止覆盖另一个客户端在两次请求之间写入的新内容。重复无条件替换,可能把自己的旧值重新盖到别人的新值之上。通过强实体标签与 If-Match,可以要求目标仍然是之前观察到的表示,才允许执行这次修改,而不是只比较方法名称。
条件重试也可能在原修改已经成功后失败:第一次写入完成却丢了响应,原先的标签已经不匹配。实验在这种情况下返回 412。RFC 9110 也允许服务器在判断所要求的修改已经应用时返回成功,因此不能把实验的一个选择当成所有服务必须采用的行为。
遇到结果不明确,应重新核对当前资源,或查询相应操作记录。一个状态码不足以还原多个写入者的全部先后历史;读到期望值,也不自动证明它只可能由自己那次请求产生。
坏掉的样子
盲目重试会放大负载、耗尽截止期限、重复不受保护的计费副作用,或覆盖中间写入。需要限制尝试次数和总耗时,保留原来的目标与请求意图,并遵守适用的服务端等待提示。幂等既不是并发控制方案,也不是无限重试的许可。
还应区分预期资源修改与附带日志、版本历史。出现两条诊断记录,本身不能证明业务效果发生了两次;反过来,两份看起来相同的成功响应,也不能证明只执行了一次业务行动。判断重复效果,需要读取有权威性的业务状态,而不是仅比对响应文本。
快速验证
保存为 methods_check.py,用 Python 3.10 以上版本执行 python3 methods_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。代码会向自己创建的回环 HTTP 服务发送真实请求,端口由系统分配,完成后停止服务;所有业务状态都留在测试进程内存中,不访问线上系统。
from http.client import HTTPConnection, RemoteDisconnected
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
state = {"body": None, "version": 0, "orders": [], "requests": []}
def etag():
return '"v%d"' % state["version"]
class Handler(BaseHTTPRequestHandler):
def handle_request(self):
state["requests"].append((self.command, self.path))
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
status, answer = 404, b""
if self.path == "/item":
if self.command in ("GET", "HEAD"):
if state["body"] is not None:
status, answer = 200, state["body"]
elif self.command == "PUT":
condition = self.headers.get("If-Match")
# The fixture accepts one exact strong entity tag, not the full grammar.
if condition is not None and (state["body"] is None or condition != etag()):
status = 412
else:
status = 201 if state["body"] is None else 204
if body != state["body"]:
state["version"] += 1
state["body"] = body
elif self.command == "DELETE":
status = 404 if state["body"] is None else 204
state["body"] = None
elif self.path == "/orders" and self.command == "POST":
state["orders"].append(body)
status, answer = 201, str(len(state["orders"])).encode()
if self.headers.get("X-Fixture-Drop-Reply") == "yes" and 200 <= status < 300:
self.close_connection = True # Apply the change, then close without a response.
return
self.send_response(status)
if self.path == "/item" and state["body"] is not None:
self.send_header("ETag", etag())
if status != 204:
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(answer)
do_GET = do_HEAD = do_PUT = do_DELETE = do_POST = handle_request
def log_message(self, *_):
pass
with HTTPServer(("127.0.0.1", 0), Handler) as server:
thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05})
thread.start()
def request(method, route="/item", body=b"", headers=None):
client = HTTPConnection(*server.server_address, timeout=2)
try:
client.request(method, route, body=body, headers=headers or {})
response = client.getresponse()
return response.status, response.read(), response.getheader("ETag")
finally:
client.close()
def lost_put(body, headers=None):
try:
request("PUT", body=body, headers={**(headers or {}), "X-Fixture-Drop-Reply": "yes"})
except RemoteDisconnected:
return
raise AssertionError("the fixture should close before sending a response")
try:
assert request("PUT", body=b"blue")[0] == 201
assert request("PUT", body=b"blue")[0] == 204
before = len(state["requests"])
assert request("GET")[1] == request("GET")[1] == b"blue"
assert len(state["requests"]) == before + 2 and state["body"] == b"blue"
assert request("HEAD")[1] == b""
assert [request("DELETE")[0], request("DELETE")[0]] == [204, 404]
print("PUT returns 201 then 204; DELETE returns 204 then 404; intended state is unchanged by repeats")
print("GET appends separate diagnostic records without changing the requested resource")
lost_put(b"blue")
assert request("GET")[1] == b"blue"
assert request("PUT", body=b"blue")[0] == 204
old_tag = request("GET")[2]
lost_put(b"green", {"If-Match": old_tag})
assert request("PUT", body=b"green", headers={"If-Match": old_tag})[0] == 412
assert request("GET")[1] == b"green"
print("a lost reply can follow a committed PUT; replaying its old precondition can return 412")
tag = request("GET")[2]
assert request("PUT", body=b"other-writer")[0] == 204
assert request("PUT", body=b"green", headers={"If-Match": tag})[0] == 412
assert request("GET")[1] == b"other-writer"
assert request("PUT", body=b"green")[0] == 204
assert request("GET")[1] == b"green"
print("a stale conditional PUT is refused; an unconditional repeat overwrites the intervening writer")
first = request("POST", "/orders", b"same order")
second = request("POST", "/orders", b"same order")
assert first[:2] == (201, b"1") and second[:2] == (201, b"2")
assert state["orders"] == [b"same order", b"same order"]
print("two identical POST bodies create two orders in this endpoint's contract")
finally:
server.shutdown()
thread.join(timeout=2)
assert not thread.is_alive()
重复 PUT 得到 201、204,重复 DELETE 得到 204、404。两次 GET 增加两条诊断记录,却不改变所请求的资源;HEAD 不返回响应体。断言将业务状态与响应分别比较,不要求幂等操作的状态码完全一致。
随后服务器故意先应用 PUT,再不发送任何响应就关闭连接。无条件重试保持目标值;带旧 If-Match 的条件写入已经成功后,再次发送旧条件会得到 412。另一个写入者的修改能抵挡过期条件请求,却会被后来的无条件替换覆盖,展示幂等和防止丢失更新并非一回事。
最后,同样的 POST 正文在这个没有幂等键的创建接口中生成两笔订单。实验验证的是传输结果不明与状态转移,不证明生产持久化、分布式并发、完整 HTTP 解析或业务去重。这里的 If-Match 只处理一个完全匹配的强标签,不实现完整字段语法;Python 基础 HTTP 服务也只作为隔离测试工具。
延伸阅读
来源
- RFC 9110,9.2.1–9.2.2 与 13.1.1 节区分安全方法、幂等性、重试和 If-Match,已核验 2026-09-08。
- MDN:幂等,预期效果、实现责任与不同响应,已核验 2026-09-08。
- Python HTTP 服务,本地实验使用的服务 API 及不适合生产部署的警告,已核验 2026-09-08。