📑 本页目录(点开跳转)
03c · 幂等与条件请求:让「重试」变成安全动作
⏱ 66 分钟 | ⭐ 在别的系统里重试是优化,在 AI 应用里它是默认会发生的事
🎯 一句话
你的接口迟早会收到同一个请求的第二份拷贝 —— 问题不是「怎么不让它发生」,是「它发生时账上会不会多一笔」。
三样东西解决它:Idempotency-Key(同一次意图只执行一次)、ETag(没变就别再传一遍)、If-Match(别无声覆盖别人的修改)。
🧩 一、为什么这一章在 AI 板块里
普通 Web 教程把幂等放在「进阶话题」。在这里它是基础题,因为本板块自己就在三处制造重复请求:
| 哪一章 | 它做了什么 |
|---|---|
| 04 · 调用层 | 重试白名单让 429 / 5xx / 超时自动重发,而读取超时是几十秒量级。⚠️ 那张表的原话:「读取超时要小心,上游可能已在生成,重试 = 付两次钱」 |
| 10 · 把流式接到界面上 | 第七节:⚠️ 前端自动重发 = 一次真金白银的新调用;结论是「一定要自动重试,就必须配合幂等键」 |
| 11 · 长任务与队列 | 第四节开头:任务被执行两次不是「可能」,是「一定」 |
⭐⭐ 根问题只有一句:出错时客户端分不出「没做成」和「做成了但回复没送到」 —— 两者在它眼里都只是一个超时。 它只能二选一:不重试(可能丢单)或重试(可能重复)。服务端的活,是让「重试」这个选项不再有代价。
💀 一个没有幂等保护的 POST /charges:上游生成慢触发 04 章那条自动重试,而上游其实已经在生成 —— 这一次请求扣了两次钱、烧了两份 token,⚠️ 全程不报错,日志里是两条漂亮的 200。
🧩 二、幂等 ≠ 安全 ≠ 相同响应
安全(safe)= 不改变服务端状态;⭐ 幂等(idempotent)= 执行 N 次和执行 1 次服务端状态相同;⚠️ 响应相同不是幂等的要求。
| 方法 | 安全 | 幂等 | 备注 |
|---|---|---|---|
GET / HEAD |
✅ | ✅ | 安全的一定幂等,反过来不成立 |
PUT |
❌ | ✅ | ⭐ 「把它替换成这个样子」 |
DELETE |
❌ | ✅ | ⚠️ 第一次 204、第二次 404,响应不同,它仍然幂等 |
POST |
❌ | ❌ | 「再来一件」 |
PATCH |
❌ | 看实现 | 见下 |
⚠️ 第三行最常被答错:幂等说的是状态 —— 两次之后那东西都没了,状态一致,变的只是响应。
⭐
PATCH幂不幂等全看这条判据:「绝对」的操作幂等,「相对」的不幂等。{"title": "新标题"}(赋值)幂等;{"views": "+1"}(增量)不幂等。同理balance = 70幂等,balance -= 30不幂等。
⚠️⚠️ 幂等是你实现出来的协议承诺,不是写了 PUT 就自动有的。 把 PUT 实现成「往列表里追加一条」照样不幂等 —— 只是它违反了所有人对 PUT 的预期,而客户端、代理、重试库全都按那个预期在行动。
⭐ 这一节兑现的是一张欠条:04 的重试白名单和 11 的死信规则都在讲「哪些错该重试」,没人讲过「哪些请求经得起被重试」。
🧩 三、Idempotency-Key:服务端拿到这个头之后 ⭐⭐
11 章第四节已经把队列侧做完了(由输入算 sha256(user|kind|payload) 当键、写进带唯一约束的表、和干活同一个事务,投三次账上 1 笔),末尾留了一句「更稳的是让客户端传 Idempotency-Key 头」。这一节是那句话的下半截。
⭐ 客户端传键和服务端算键,差在「谁来判断这是不是同一次意图」:
同一个用户故意用完全相同的参数下两单(买两份一样的、把同一份文档再总结一版)—— 服务端算出的键一模一样,第二次被当成重试吞掉了。⚠️ 用户没收到任何错,只是东西没出现。
⚠️ 客户端唯一的硬规矩:键在发起时生成一次、重试时原样带回,每次重试重新生成 = 等于没做。⭐ 这和 11 章「幂等键不能用 uuid4()」是同一个错的两副面孔,但结论相反:在这里 uuid4() 是对的,只要它在一串重试之间不变。
服务端要回答四个问题:
| 问题 | 答案 | 为什么 |
|---|---|---|
| ① 同键、不同 body | 409,绝不执行 | ⭐ 422 说的是「请求本身有毛病」,可这里 body 完全合法 —— 毛病在「它和这个键先前绑的那份对不上」,那是请求与当前状态的冲突 |
| ② 并发同键 | 409,⚠️ 不要挂着等 | 靠唯一约束抢占:插得进去的干活,撞约束的知道有人在干。挂着等意味着两条连接被占几十秒,就是 06 章那个「持有时长才是瓶颈」 |
| ③ 同键、同 body | ⭐⭐ 重放第一次的响应 | 见下 |
| ④ 键存多久 | 由「客户端最长的重试窗口」定 | 前端手动重试、11 章的退避(MAX_ATTEMPTS = 5 封顶 60 秒)都是分钟级,跨天跑的补偿脚本是天级。🗓️ 常见 24 小时量级,⚠️ 这个数必须写进 API 文档,客户端才知道超过多久重试就不再安全 |
⭐⭐ ③ 是它和「只是去重」的分界线。 11 章那段返回的是 'skipped',在队列侧够用(投递方不关心结果);接口侧不行:
客户端之所以重试,恰恰是因为它没收到第一次的响应。 你回它一句「这个做过了」,它拿不到订单号、拿不到 job id、拿不到那段结果 —— 等于失败。
所以要把第一次的状态码和响应体存下来原样重放,并让客户端能分辨这是重放(🗓️ 头的名字各家不统一)。⚠️ 代价是存储,以及响应体里可能有用户原话,留存期要和《数据这一关》19 章对齐。⚠️ 流式重放不了:05 章讲过状态码在第一帧发出时就定死成 200,SSE 边生成边发,你手上没有一份「完整响应」可存 —— ⭐ 务实的形状是把花钱的那一步和流分开:先用一个带幂等键的普通 POST 创建 / 预扣,拿到 id 再去开流。
⭐⭐ 键必须限定在租户内。 这是重放机制自己带来的安全洞:既然「拿着键就能拿到那次响应」,全局唯一的键就意味着 A 租户猜中 B 的键便能读到 B 的响应体。所以主键是 (tenant_id, key),而那个 tenant_id ⚠️ 只能来自已验签的凭证(08 章)。
# idem_server.py —— Idempotency-Key 的服务端协议(sqlite3,直接可跑)
import sqlite3, hashlib, json
db = sqlite3.connect(":memory:")
db.execute("""CREATE TABLE idem(
tenant TEXT, key TEXT, fingerprint TEXT, -- ⭐ 指纹:同键不同 body 靠它认出来
status TEXT, -- in_progress / done
resp_code INTEGER, resp_body TEXT, -- ⭐⭐ 第一次的响应存这,重试时原样重放
PRIMARY KEY (tenant, key))""") # ⭐⭐ 键限定在租户内,不能全局唯一
db.execute("CREATE TABLE orders(id TEXT PRIMARY KEY, tenant TEXT, cents INTEGER)")
SEQ = [0]
def fp_of(body): # ⚠️ sort_keys —— 字段顺序一变指纹就变,会把正常重试误判成「不同 body」
return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:12]
def create_order(tenant, key, body):
fp = fp_of(body)
try:
with db: # ⭐ 抢占:唯一约束保证同一时刻只有一个请求能插进来
db.execute("INSERT INTO idem(tenant,key,fingerprint,status)"
" VALUES(?,?,?,'in_progress')", (tenant, key, fp))
except sqlite3.IntegrityError:
r = db.execute("SELECT fingerprint,status,resp_code,resp_body FROM idem"
" WHERE tenant=? AND key=?", (tenant, key)).fetchone()
if r[0] != fp: # ① 同键不同 body
return 409, {"code": "idempotency_key_reused", "message": "这个键绑的是另一个请求体"}
if r[1] == "in_progress": # ② 并发同键,⚠️ 不挂着等
return 409, {"code": "request_in_progress", "message": "同一个键正在处理中"}
return r[2], {**json.loads(r[3]), "replayed": True} # ⭐⭐ ③ 重放,不是「跳过」
SEQ[0] += 1
out = {"order_id": "ord_%d" % SEQ[0], "cents": body["cents"]}
with db: # ⭐ 干活和写响应在同一个事务里(11 章第四节同款理由)
db.execute("INSERT INTO orders VALUES(?,?,?)", (out["order_id"], tenant, body["cents"]))
db.execute("UPDATE idem SET status='done', resp_code=?, resp_body=?"
" WHERE tenant=? AND key=?", (201, json.dumps(out), tenant, key))
return 201, out
b = {"cents": 3000, "item": "pro-plan"}
print("① 第一次 ", create_order("acme", "k-1", b))
print("② 原样重试 ", create_order("acme", "k-1", b))
print("③ 同键换 body ", create_order("acme", "k-1", {"cents": 9900, "item": "pro-plan"}))
print("④ 别的租户同键 ", create_order("globex", "k-1", b))
print("⑤ 换个键 ", create_order("acme", "k-2", b))
with db: # 手动造一个「另一个请求正在处理中」的状态
db.execute("INSERT INTO idem(tenant,key,fingerprint,status)"
" VALUES('acme','k-3',?,'in_progress')", (fp_of(b),))
print("⑥ 同键正在处理中", create_order("acme", "k-3", b))
print("库里的订单:", db.execute("SELECT id,tenant,cents FROM orders").fetchall())
实跑输出:
① 第一次 (201, {'order_id': 'ord_1', 'cents': 3000})
② 原样重试 (201, {'order_id': 'ord_1', 'cents': 3000, 'replayed': True})
③ 同键换 body (409, {'code': 'idempotency_key_reused', ...})
④ 别的租户同键 (201, {'order_id': 'ord_2', 'cents': 3000})
⑤ 换个键 (201, {'order_id': 'ord_3', 'cents': 3000})
⑥ 同键正在处理中 (409, {'code': 'request_in_progress', ...})
库里的订单: [('ord_1', 'acme', 3000), ('ord_2', 'globex', 3000), ('ord_3', 'acme', 3000)]
⭐ ② 和 ⑤ 对着看:同一份 body,键相同就重放同一个 ord_1,键换了就真下了一单 ord_3 —— 「是不是同一次意图」的判断权在客户端手里。⭐ ④ 是那个安全洞的正面演示:globex 用了同一个字符串 k-1,拿到的是自己的 ord_2。
(code / message 沿用 03 章那套统一错误形状,真接进服务时还要带 trace_id。这一章不重新定义错误长什么样,只决定该返哪个码。)
🛑 读到这里可以停 —— 前半章讲完了(约 28 分钟)。 后半章还有:条件请求:那个 304 是怎么来的 ·
If-Match就是 HTTP 层的乐观锁 · 把三件事拼起来 · 换个栈怎么对应 回来的时候不用重读,直接从下一节接着看就行。
🧱 四、条件请求:那个 304 是怎么来的
上面解决的是「写」。读这一侧也有重复:用户每隔几秒刷一次会话列表,内容一个字没变,你却把整段 JSON 又发了一遍。
机制三步:① 响应带 ETag: W/"c_42-v7"(这个资源当前这一版的指纹)② 客户端下次带 If-None-Match: W/"c_42-v7" ③ 服务端一比对,一样就返 304 Not Modified,⭐ 不带 body。
⚠️ 304 省的是 body,不是往返 —— TLS、鉴权、以及你为了算 ETag 做的那次查库都还在。⭐ 判据:body 大、变化少的才值得做(会话列表、文档正文、前端配置),一个二十字节的小 JSON 别折腾。
⭐ ETag 怎么算,两条路:内容哈希最准,但 ⚠️ 省不了计算,只省传输;⭐ 版本派生("{id}-v{version}" 或 "{id}-{updated_at}")便宜得多 —— 06 章那张表上本来就有 updated_at。⚠️ 强 ETag("abc")承诺字节级完全相同,弱 ETag(W/"abc")只承诺语义等价,有压缩和中间层改写时强的容易被弄坏,拿不准就用弱的。Last-Modified 精度只到秒,回滚还会让时间和内容对不上 —— 两个都能给就给 ETag。
⚠️ 两个必须点破的边界:
- ⭐⭐ 它和 04 章的 LLM 结果缓存是两件事:那一章问「同样的输入要不要再问一次模型」(省的是钱,判据是
temperature=0、不带工具、不流式),这一节问「同样的资源要不要再传一次字节」(省的是流量)。一个在调用层、一个在 HTTP 层,两层都做不冲突。 - 流式端点别做 ETag:发第一个字节的时候,你还不知道最终内容是什么。
🔍 五、If-Match 就是 HTTP 层的乐观锁
把方向反过来:If-None-Match 用于读(「没变就别给我」),If-Match 用于写(「没被别人改过我才写」)。它治的是丢更新:
A 和 B 同时打开同一个会话的标题编辑框,都看到
v7。A 存了,B 十秒后也存了。 ⚠️ B 的PUT把 A 的改动整段覆盖,没有任何报错 —— A 只是过一会儿发现自己改的东西没了。
流程:GET 拿到 ETag → 改 → PUT 时带 If-Match → 服务端比对,对不上返 412 Precondition Failed。⭐ 服务端实现只有一条 SQL,不需要任何锁:
UPDATE docs SET title = ?, version = version + 1
WHERE id = ? AND version = ? -- ⭐ 受影响行数 = 0 就是冲突,返 412
| 码 | 含义 | 本章的例子 | 客户端该做什么 |
|---|---|---|---|
| 412 | ⭐ 你自己提的前置条件不成立 | If-Match 对不上 |
重新 GET → 合并 → 再提交 |
| 409 | 请求和服务器当前状态打架,但你没提任何条件 | 同键不同 body、并发同键 | 换个键 / 稍后重试 |
⭐ 记法:412 是「你提的条件不成立」,409 是「你和服务器现在的状态对不上」。 412 的可贵之处是客户端知道下一步该干什么;409 往往只能告诉它「别再原样重试了」。
⚠️ 对多人可同时改的资源要强制带 If-Match,不带就拒(🗓️ 正式做法是返 428 Precondition Required)—— 不强制的话,忘了带的那个客户端仍然在无声覆盖别人。想表达「我就是要覆盖,只要它还在」,标准写法是 If-Match: *。
⭐⭐ 为什么这个板块尤其该用乐观锁:06 章第六节警告过「别在 SELECT ... FOR UPDATE 之后调外部服务 —— 那是攥着一把行锁在等」(那节的可跑对照:池 5、并发 40、LLM 1 秒时 8.15 秒 vs 1.15 秒)。而 AI 应用最典型的写路径正是「读一份文档 → 调模型改写(几十秒) → 写回去」,悲观行锁在这段时间根本不能用,版本号则什么都不锁。⚠️ 代价是冲突处理被推给了客户端,所以 412 至少要带上当前版本,否则客户端只能盲目重 GET 再赌一次。
💡 同一个形状后面还会再见到:第 11 章那条队列认领语句里的 WHERE status='queued',本质就是拿状态当版本号的乐观锁。
# conditional.py —— 条件请求的两次比对(纯 stdlib 可直接跑;框架写法见文末对照表)
DOC = {"id": "c_42", "title": "旧标题", "version": 7}
def etag():
return 'W/"%s-v%d"' % (DOC["id"], DOC["version"]) # ⭐ 版本派生,不必读全文
def GET(if_none_match=None):
if if_none_match == etag():
return 304, None # ⭐ 没变 → 不带 body
return 200, dict(DOC, etag=etag())
def PUT(title, if_match=None):
if if_match != etag(): # ⭐ 对不上 = 别人先改过了
return 412, {"message": "已被改过,请重新 GET 后再提交", "now": etag()} # ⭐ 带上当前版本
DOC.update(title=title, version=DOC["version"] + 1)
return 200, dict(DOC, etag=etag())
tag = GET()[1]["etag"] # A 和 B 都拿到 v7
print("① 带 If-None-Match:", GET(tag))
print("② A 写入:", PUT("A 的标题", tag))
print("③ B 拿着旧 ETag 写:", PUT("B 的标题", tag))
实跑输出:
① 带 If-None-Match: (304, None)
② A 写入: (200, {'id': 'c_42', 'title': 'A 的标题', 'version': 8, 'etag': 'W/"c_42-v8"'})
③ B 拿着旧 ETag 写: (412, {'message': '已被改过,请重新 GET 后再提交', 'now': 'W/"c_42-v8"'})
⭐ 第 ③ 行是这一节的全部价值:没有 If-Match 时它会是 200、标题会变成「B 的标题」—— A 的改动被无声吃掉,全程零报错。
📋 六、把三件事拼起来
创建 / 扣费 / 提交任务(POST)用 Idempotency-Key + 重放,冲突返 409;整体替换(PUT)天然幂等,再加 If-Match 防丢更新,冲突返 412;局部修改(PATCH)⚠️ 先确认补丁是「绝对」的再加 If-Match;反复拉同一份大 body(GET)用 ETag + If-None-Match,命中返 304(这不是错)。
⭐⭐ 一条能直接写进代码评审清单的规矩: 每一个会花钱或改状态的
POST,要么带Idempotency-Key,要么重新设计成PUT。 两条都不满足的,就是在等一次双倍扣款。
⚠️ 客户端也有三件事必须配合:① 键原样带回;② 尊重 429 带回来的 Retry-After(12 章讲它怎么从令牌桶算出来);③ 退避加抖动。
🔄 换个栈怎么对应
| 概念 | Python / FastAPI(本板块主栈) | Node(Express / Hono) | Go |
|---|---|---|---|
| 读请求头 | Header(default=None)(下划线自动转连字符) |
req.get('Idempotency-Key') / c.req.header(...) |
r.Header.Get("Idempotency-Key") |
| 幂等键存储 | ⭐ 一张带唯一约束的表 —— 就是 06 章那个库 | 同左 | 同左 |
| 返 304 | Response(status_code=304) |
res.status(304).end() |
w.WriteHeader(http.StatusNotModified) |
| ⚠️ 自动 ETag | 没有内置,自己算 | 🗓️ Express 有个 etag 应用设置(给 res.send 加弱 ETag) |
http.ServeContent 只对静态文件处理条件请求 |
| 乐观锁 | UPDATE … WHERE version = ? 看受影响行数 |
同左 | 同左 |
⭐ 这张表要看出的是:哪些是概念、哪些只是这个框架的叫法。
没有任何一个框架内置 Idempotency-Key —— 它必须落在你的存储上、按你的业务语义决定什么算「同一次意图」,外包不出去。而 ETag/304 有的框架替你做了一半,⚠️ 可它替你做的恰好是「把整个 body 算出来再哈希」那一半 —— 省传输不省计算,真正值钱的版本派生仍然得你自己写。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| ⭐⭐ 11 · 长任务与队列 | 本章是它第四节末尾「更稳的是让客户端传 Idempotency-Key 头」那句话的下半截。⭐ 两侧一起看:那边管「同一个任务被消费两次」(服务端算键),这边管「同一个请求被发两次」(客户端传键) |
| ⭐ 04 · 调用层 | 本章存在的直接原因:那一章的白名单会自动重发 429/5xx/超时,而它自己那张表就写着「读取超时时上游可能已在生成,重试 = 付两次钱」 |
| 10 · 把流式接到界面上 | 第七节说「一定要自动重试就必须配合幂等键」—— 服务端那一半在本章第三节:键怎么存、怎么重放、怎么按租户隔离 |
| ⭐ 06 · 关系数据库 | 乐观锁的 version 列和幂等键表都落在那一章的库上。⭐ 反过来也成立:那一章禁止攥着行锁调外部服务,本章的版本号正是那条禁令下还能用的并发控制 |
| ⭐ 03b · 接口的形状 | 那一章讲方法语义和状态码的整体选择,只点一句「PUT 幂等、POST 不幂等」;⭐ 幂等的全部展开在本章,本章也只碰 409 / 412 两个码 |
| 08 · 认证、会话与多租户 | 幂等键必须限定在租户内,⚠️ 而那个 tenant_id 只能来自已验签的凭证 —— 从请求参数里取就是一行越权 |
| 16 · 上线前检查单 | 那份清单里「同一个 Idempotency-Key 投三次 → 账上只有一笔」,验的就是本章第三节 |
✅ 检查点
- 「安全」「幂等」「相同响应」分别是什么?
DELETE第二次返 404,它还幂等吗?PATCH幂不幂等取决于什么? - 客户端传
Idempotency-Key比服务端自己算键好在哪?客户端唯一的硬规矩是什么? - 同键不同 body 为什么返 409 而不是 422?并发同键为什么不能挂着等?
- 为什么「只去重、回一句 skipped」在接口侧不够用?流式路径上为什么重放不了?
- 幂等键表的主键为什么必须是
(tenant_id, key)?键该存多久,判据是什么? 304省掉的是什么、没省掉的是什么?它和 04 章的 LLM 结果缓存差在哪?412和409怎么分?为什么这个板块更该用乐观锁而不是SELECT ... FOR UPDATE?
👀 答案
- 安全 = 不改变服务端状态;幂等 = 执行 N 次和 1 次服务端状态相同;响应相同不是幂等的要求 —— ⭐
DELETE第二次返 404 仍然幂等,两次之后那东西都没了。PATCH取决于补丁语义:⭐ 「绝对」的操作幂等({"title": "新标题"}),「相对」的不幂等({"views": "+1"})。 - 服务端算的键分不出「重试」和「用户故意再来一次」 —— 同一个用户用相同参数下两单,算出的键一样,第二单被当成重试吞掉,用户还收不到任何错。客户端传键把「是不是同一次意图」的判断权交回发起方。硬规矩:发起时生成一次、重试时原样带回;⭐ 这里用
uuid4()反而是对的(11 章说不能用它,指的是服务端每次重算的场景)。 422说的是「请求本身有毛病」,而这里的 body 可能完全合法,毛病在「它和这个键先前绑的那份对不上」—— 那是请求与当前状态的冲突,所以是409。不能挂着等,是因为一次调用几十秒,挂等把两条连接一起占住,正是 06 章那个「持有时长才是瓶颈」。- ⭐⭐ 客户端之所以重试,恰恰是因为它没收到第一次的响应;回它「做过了」,它拿不到订单号 / job id / 生成结果,等于失败。所以要存下第一次的状态码和响应体原样重放(队列侧返
skipped够用,是因为投递方不关心结果)。⚠️ 流式重放不了:状态码在第一帧发出时就定死成 200,SSE 边生成边发,没有一份完整响应可存。 - 因为重放意味着「拿着键就能拿到那次响应」,全局唯一的话 A 租户猜中 B 的键就能读到 B 的响应体(代码里 ④:
globex用同一个k-1拿到的是自己的ord_2)。存多久由「客户端最长的重试窗口」定(🗓️ 常见 24 小时量级),而且必须写进 API 文档。 - 省的是 body;⚠️ 没省往返 —— TLS、鉴权、为算 ETag 做的查库都还在,所以只有 body 大、变化少的接口值得做。⭐ 和 LLM 结果缓存是两件事:那个问「要不要再问一次模型」(省钱),这个问「要不要再传一次字节」(省流量)。
- 412 = 你自己提的前置条件不成立(
If-Match对不上),客户端明确知道要重新 GET 再提交;409 = 请求和当前状态打架但你没提条件。该用乐观锁是因为写路径是「读 → 调模型(几十秒) → 写回」,悲观行锁会攥着锁等外部服务,正是 06 章明令禁止的(对照:池 5 / 并发 40 / LLM 1 秒,8.15 秒 vs 1.15 秒);⚠️ 代价是冲突处理被推给客户端。
🛑 可以停在这里
⚡ 走神救援
根问题:出错时客户端分不出「没做成」和「做成了但回复没送到」,只能在丢单和重复之间二选一 —— 服务端的活是让「重试」不再有代价。⭐ 这在本板块是基础题,因为板块自己就在制造重复:04 章的白名单自动重发
429/5xx/超时(原话「上游可能已在生成,重试 = 付两次钱」)、10 章的前端重发是「一次真金白银的新调用」、11 章说任务被执行两次不是可能是一定。 三个概念别混:安全 = 不改状态;⭐ 幂等 = 执行 N 次和 1 次服务端状态相同;⚠️ 响应相同不是幂等的要求(DELETE第二次返 404 仍然幂等)。PUT/DELETE幂等不安全,POST都不是,PATCH看实现 —— ⭐ 「绝对」的操作幂等,「相对」的不幂等。⚠️ 幂等是你实现出来的承诺,不是写了PUT就自动有的。 ⭐⭐Idempotency-Key的服务端协议(11 章第四节末尾那句的下半截):客户端传键强过服务端算键,差别在谁判断「是不是同一次意图」 —— 服务端算sha256(user|kind|payload)会把「用户故意再下一单」当成重试吞掉。客户端硬规矩:发起时生成一次、重试时原样带回(⭐ 这里uuid4()反而是对的)。服务端答四问:① 同键不同 body → 409 不是 422(422 是「请求本身有毛病」,可这里 body 完全合法);② 并发同键 → 唯一约束抢占后返 409,⚠️ 绝不挂着等(一次调用几十秒,挂等就是 06 章那个「持有时长才是瓶颈」);③ ⭐⭐ 必须重放第一次的响应,而不是回一句「跳过」 —— 客户端重试恰恰是因为没收到第一次的响应,回「做过了」它拿不到订单号,等于失败;⚠️ 流式重放不了(状态码第一帧就定死成 200),务实做法是把花钱那步和流分开;④ 存多久由「客户端最长的重试窗口」定(🗓️ 24 小时量级),必须写进 API 文档。⭐⭐ 主键必须是(tenant_id, key)—— 重放意味着「拿着键就能拿到那次响应」,全局唯一就是越权读。 条件请求:响应带ETag→ 下次带If-None-Match→ 一样就返 304 不带 body。⚠️ 304 省 body 不省往返,所以只有 body 大、变化少的接口值得做;内容哈希最准但省不了计算只省传输,⭐ 版本派生便宜得多。⚠️ 它和 04 章的 LLM 结果缓存是两件事:那个问「要不要再问一次模型」(省钱),这个问「要不要再传一次字节」(省流量)。 ⭐If-Match就是 HTTP 层的乐观锁,治丢更新:A、B 都拿着v7,A 先存 B 后存,B 无声覆盖 A,全程零报错。GET拿 ETag →PUT带If-Match→ 对不上返 412;服务端只要一条UPDATE … SET version = version + 1 WHERE id = ? AND version = ?,受影响行数 0 就是冲突,不用任何锁。⭐ 412 是「你提的条件不成立」,409 是「你和服务器当前状态打架」。 ⭐⭐ 这个板块尤其该用乐观锁:写路径是「读 → 调模型几十秒 → 写回」,而 06 章明令禁止攥着行锁调外部服务(对照 8.15 秒 vs 1.15 秒);代价是冲突处理推给客户端,所以 412 至少要带上当前版本。 ⭐⭐ 写进评审清单的一条:会花钱或改状态的POST,要么带Idempotency-Key,要么重新设计成PUT。 换栈时不变的是:没有框架内置Idempotency-Key,而框架替你做的那半个 ETag 恰好是「把 body 算出来再哈希」—— 省传输不省计算。
下一节 👉 04-调用层.md