📑 本页目录(点开跳转)
03b · 接口的形状:资源怎么划、方法怎么选、状态码怎么返
⏱ 76 分钟 | ⭐ 第 11 章立了「400/401 直接进死信,429/500/超时才重试」—— 那条规则的另一半在这里
🎯 一句话
状态码不是给人看的装饰,是你对客户端下的一道指令:这个错该不该重试、这个请求还要不要再发。 后面好几章都在读你返回的那个数字并据此行动,却没有一章讲你该怎么写它。
🧩 一、先把洞指出来:一条规则只有一半
第 11 章第五节立了一条判据,第 16 章的验收项就是照它写的:
⚠️ 还要分清能不能重试:
400、401、内容被安全策略拒绝,重试一万次也是同样结果,直接进死信;只有429、500、超时才值得退避重试。
第 4 章的重试白名单、第 12 章的「429 必须带 Retry-After」,都建在同一件事上:对面返回的那个数字是可信的。
⚠️ 但到这一章为止,本板块的业务接口基本只有 POST /chat 一种形状(GET 只出现在查任务和健康检查上),而「该返哪个状态码」只被顺手提过几次(03 章的 422、11 章的 202、12 章的 429),没有一处讲判据。规则的消费端讲透了,生产端是空的。 而写错一个码,就是让对面做出错误的决定:
| 你写错的方向 | 对面会做什么 | 症状 |
|---|---|---|
| 「prompt 超长」返成 500 | 按白名单重试 3 次 | 本该 0.2 秒的确定性错误拖成 8 秒,⚠️ 日志里像上游在抖(04 章原话) |
| 「上游 502」翻译成 400 | 一次就进死信 | 瞬时故障变成永久丢件,⚠️ 死信里躺着本该重试一次就好的活 |
| 「没权限」返成 401(该 403) | 前端跳登录页 | 登录成功 → 又被踢回登录页 → 无限循环 |
| 「还在排队」返成 201 | 立刻去拿结果 | GET 那个地址得到 404,前端分不清是自己拿早了还是你搞砸了 |
⭐⭐ 本章主判据:每个状态码都在回答两个问题 —— 这是谁的错?对面该不该再发一次? 别的判据(好不好看、REST 纯不纯)都排在这后面。
顺序是 先划资源 → 再选方法 → 最后定状态码;跳过前两步直接背状态码表,很多码根本没地方用。
🧱 二、资源怎么识别:名词不是动词
❌ POST /createConversation ❌ POST /getMessages ❌ POST /deleteMessage
✅ POST /conversations ✅ DELETE /messages/{mid} ✅ GET /conversations/{cid}/messages
⚠️ 这不是风格洁癖。 左边一列的共同点是方法全为 POST,于是所有中间层(反向代理、CDN、浏览器、日志、限流器、04 章那个重试器)看到的都是「一个不透明的 POST」——能不能缓存?重发安不安全?是读还是写?全推不出来。 你把信息藏进了 URL 字符串,而没有一个中间层会去读它。
⭐ URL 层级和第 6 章那两张表一一对应:/conversations/{cid}/messages 正是那章实测的热路径 WHERE conversation_id=? ORDER BY seq(建 (conversation_id, seq) 索引后 200 次查询 3.802 秒 → 0.0019 秒)。这不是巧合 —— 两者都在回答「这批数据按什么维度成批取出」。
⭐ 什么时候该单独建一个资源?判据:这件事有没有自己的生命周期 —— 需不需要被单独查询、单独重试、单独取消?
「总结这段文字」300 毫秒同步返回 → ❌ 没有中间状态,就是一次 POST;「总结这份 200 页 PDF」跑 3 分钟 → ✅ 有了 queued / running / done、有了查进度和取消,这三样一出现它就必须有自己的 id。⭐ 第二种就是第五节的引子:它同时是「一条消息」和「一个任务」,两种建模会打架。 ⚠️ 反方向的过度也真实存在 —— 别机械地给每张表配五个端点,判据同样是「有没有客户端真的要用」。
🤝 三、方法是承诺,不是规矩
选 GET 还是 POST 感觉像按规矩办事。实际上它是你向所有客户端和中间层公开的一份承诺,而它们真的照着行事,不问你一句。
| 方法 | 安全(不改状态) | 幂等(重发,终态一样) | ⭐ 谁在依赖这个承诺 |
|---|---|---|---|
GET |
✅ | ✅ | 浏览器预取、CDN 缓存、爬虫跟着链接走、网络抖动时自动重发 |
POST |
❌ | ❌ | ⭐ 什么都不承诺,所以中间层对它最保守 —— 不缓存、不预取、不自动重发 |
PUT |
❌ | ✅ | 整体替换。同一份 body 重发,终态相同 |
PATCH |
❌ | ⚠️ 不保证 | 局部修改。给绝对值就幂等,给「计数 +1」就不是 |
DELETE |
❌ | ✅ | 删两次终态都是「没了」——⭐ 所以第二次不该报 404 |
⭐ 幂等在本章只有一句话的分量:
PUT/DELETE承诺重发安全,POST没有。 怎么让不幂等的POST也能安全重发(Idempotency-Key、ETag、条件请求)是下一章 03c 整章的事。
💀 最贵的一条:把会改状态的操作写成 GET(GET /messages/{mid}/delete)。它能跑、测试也过,然后 —— 阅读器和浏览器会预取(带这个链接的页面被打开一遍,东西就没了)、CDN 会缓存住那个「成功」(下次点击根本没到你的服务器)、重试器会自动重发、⚠️ 参数还会被各层记进日志(和第 8 章那行 GET /api/docs?tenant=globex 是同一个家族的问题)。⭐ 反方向(把纯读写成 POST /search)代价温和些:丢掉缓存、丢掉「链接发给同事就能复现」;⚠️ 合法例外只有一个 —— 查询体太大放不进 URL。
🚦 AI 应用的一个现实:生成天然不幂等。 第 2 章四件难事的最后一条就是「同样输入不同输出」—— 同一段 prompt 发两次会得到两条回复、两笔账。⭐ 所以发消息就该是 POST,用承诺幂等的 PUT 去包装它是在对客户端撒谎;正确做法是承认它不幂等,再单独加一层保护(03c,也正是 11 章第四节结尾「更稳的是让客户端传 Idempotency-Key 头」指向的地方)。
# DELETE 的幂等承诺落成代码:删两次都该是 204
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
CONV = {"c1": {"title": "报销问题", "archived": False}}
@app.patch("/conversations/{cid}") # 局部修改:给哪个字段改哪个
def patch_conv(cid: str, body: dict):
CONV[cid].update(body) # ⚠️ body 若是"计数 +1"这类相对操作,这个接口就不幂等
return CONV[cid]
@app.delete("/conversations/{cid}", status_code=204) # ⭐ 幂等:删两次,终态都是"没了"
def del_conv(cid: str):
CONV.pop(cid, None) # ⚠️ 第二次不报 404 —— 终态一样就该给一样的答复
return None
c = TestClient(app)
print("PATCH ", c.patch("/conversations/c1", json={"title": "报销流程"}).json())
print("DELETE x2", [c.delete("/conversations/c1").status_code for _ in range(2)])
实跑:PATCH 只改了给的那个字段({'title': '报销流程', 'archived': False}),⭐ DELETE 连发两次都是 [204, 204] —— 第二次没有报 404。客户端重试时要的是「终态对不对」,不该因为「上一次其实成功了」而收到错误。
🚦 四、状态码:判据是「谁的错、要不要重试」
⭐⭐ 两位数就定大方向:4xx = 你别再这么发了(原样重发结果一样,问题在请求里);5xx = 我这边的问题,你可以再试。11 章那条死信规则和 04 章那张白名单,都只是这条通则的应用。
| 成功侧 | 意思 | 用在哪 |
|---|---|---|
| 200 | 成了,结果在 body 里 | 默认 |
| 201 Created | ⭐ 新资源已经存在了 | 建了条消息/会话,现在就能拿到。顺着 Location 去取 |
| 202 Accepted | ⭐⭐ 收到了,但还没答应你它会成功 | 活扔进队列(11 章那五条信号)。顺着 Location 轮询任务 |
| 204 No Content | 成了,没有 body | DELETE、纯状态切换。⚠️ 前端别去 JSON.parse("") |
| 4xx | 它在回答的问题 | 典型场景 |
|---|---|---|
| 400 | 我根本没读懂你的请求 | body 不是合法 JSON、必需的头缺了 |
| 401 | 我不知道你是谁 | 没带 token / 过期。⭐ 要带 WWW-Authenticate |
| 403 | 我知道你是谁,但不许 | 只读 token 想写、免费版调付费模型 |
| 404 | 它不存在(或:我不打算告诉你它存不存在) | id 写错、⭐ 跨租户访问 |
| 409 | 请求没问题,是资源现在的状态不允许 | 对已经 done 的 job 发取消 |
| 422 | 我读懂了,但内容不合法 | max_tokens=99999、空 message(03 章那行) |
| 429 | 你太快了 / 额度不够 | ⭐ 必须带 Retry-After(12 章讲了它怎么算) |
⭐ 401 vs 403 一句话:401 = 换个凭证可能就行(跳登录页);403 = 换凭证也没用(显示「没权限」)。 ⚠️ 混用的症状很典型:用户登录成功、被跳回来、又是 403、又跳登录 —— 无限循环,而日志里全是成功的登录。 ⭐ 400 vs 422 就没那么要紧(FastAPI 里 Pydantic 校验失败默认 422,两者在重试语义上同一档),写出来只是为了让你知道哪些地方不随便。
⭐⭐ 409 是 4xx 通则的例外,也是 11 章那条规则的缺角
409 不是「你错了」,是「你现在这么发不行」:对已跑完的 job 发取消、两个请求同时改同一行。⚠️ 它是少数几个「过一会儿再发同样的请求可能就成功」的 4xx(等那个 job 跑完、等那把锁放开)。把 409 一股脑塞进死信,你会丢掉一批只是撞上时序的任务。
⭐ 补全后的分诊表 —— 这就是 11 章那条规则缺的另一半:
| 服务端返回 | 谁的错 | 客户端该做 | 队列里 |
|---|---|---|---|
400 / 422 |
你 | 改请求再发 | 死信 |
401 |
你(凭证) | 去登录,拿新凭证再发 | 死信(后台任务没法「去登录」) |
403 |
你(权限) | 别再发了 | 死信 |
404 |
你(大概) | 别再发了 | 死信。⚠️ 例外:刚创建就查会撞时序,退避重试一两次 |
⭐ 409 |
时序 | ⭐ 退避后重发同样的请求 | ⭐ 重试(有上限),⚠️ 不是死信 |
429 |
你太快 / 额度 | 按 Retry-After 等,加抖动 |
重试 |
5xx / 超时 |
我 | 退避重试 | 重试 |
⭐ 跨租户到底返 404 还是 403?16 章那条验收项写的是「用 A 的 token 请求 B 的资源 id → 404 / 403」,两个都算过、没说选哪个。判据在这儿:
⭐ 403 承认了「这个 id 存在」。 id 一旦可枚举,它就成了探测接口 —— 拿一个合法账号就能数出你有多少客户、哪些 id 是活的。 判据:如果「存在与否」本身是敏感的,就返 404。 ⚠️ 代价是你自己排查时也分不清「真没有」和「没权限」,⭐ 解法是对外统一 404、日志里记真实原因和
trace_id(03 章那条原则)。
# 五个 4xx 各自在回答哪个问题:判据不是"错了",是"谁的错、要不要重试"
from fastapi import FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI()
TOKENS = {"t_u1": ("u1", True), "t_ro": ("u1", False)} # token -> (谁, 能不能写)
JOBS = {"j_run": ("u1", "running"), "j_done": ("u1", "done"), "j_other": ("u2", "running")}
class CancelIn(BaseModel):
reason: str = Field(min_length=1, max_length=200) # ⭐ 空 reason 进业务之前就被 422 挡掉
@app.post("/jobs/{jid}/cancellation")
def cancel(jid: str, body: CancelIn, authorization: str = Header(default="")):
who = TOKENS.get(authorization[7:]) if authorization.startswith("Bearer ") else None
if who is None: # ⭐ 401 = 不知道你是谁,换个凭证可能就行
raise HTTPException(401, "unauthenticated", headers={"WWW-Authenticate": "Bearer"})
me, can_write = who
owner, status = JOBS.get(jid, (None, None))
if owner != me: # ⭐ 别人的也返 404:403 会承认它存在
raise HTTPException(404, "no such job")
if not can_write: # ⭐ 403 = 知道你是谁但不许,别跳登录页
raise HTTPException(403, "read-only token")
if status != "running": # ⭐⭐ 409 = 资源【现在的状态】不允许
raise HTTPException(409, "job already " + status)
return {"status": "cancelling"}
c, ok = TestClient(app), {"reason": "点错了"}
for tok, jid, body in [("", "j_run", ok), ("t_u1", "j_run", {"reason": ""}),
("t_u1", "j_other", ok), ("t_ro", "j_run", ok),
("t_u1", "j_done", ok), ("t_u1", "j_run", ok)]:
h = {"Authorization": "Bearer " + tok} if tok else {}
print(c.post(f"/jobs/{jid}/cancellation", json=body, headers=h).status_code, end=" ")
六个用例依次是「没带 token / reason 空串 / 别人的 job / 只读 token / 已经 done 了 / 正常」,实跑输出 401 422 404 403 409 200。⭐ 判断顺序本身也是设计:先认人(401)→ 判归属(404)→ 判权限(403)→ 判状态(409)。⚠️ 颠倒会漏信息 —— 先判状态的话,别人靠「这个 id 返 409 而不是 404」就能推断出它存在且正在运行。
🧯 唯一一处状态码用不了的地方:第 5 章那条结论 —— 流式下状态码在第一帧发出去的那一刻就定死成 200,改不了。 ⭐ 对本章的意义只有一句话:上面这些码必须在第一个字发出去之前全部用完。
🛑 读到这里可以停 —— 前半章讲完了(约 35 分钟):资源怎么划、方法承诺了什么、状态码怎么选。 后半章还有:一次对话既是资源又是长任务 · 错误响应里状态码和
code的分工 · 换个栈怎么对应 回来的时候不用重读,直接从下一节接着看就行。
🔍 五、一次对话既是资源,又是长任务
⭐⭐ 这一节是本章唯一一个别的 Web 教程不会讲的东西。 两套建模各自都对:资源视角(第 6 章)发一条消息 = 往 messages 加一行 → 201;任务视角(第 11 章)这次生成要 3 分钟、有状态、要查进度、要重试 → 202。
⚠️ 打架的点:同一个用户动作同时属于两边。 用户点的是「发送」,落地却是一条消息加一个任务。只选一边都缺东西 —— 只做消息资源,客户端拿不到进度、没法取消;只做 job,任务完成后前端不知道结果落在哪儿,只能整段重拉会话,用户自己刚打的那句话也没法立刻显示。
| 这次生成 | 状态码 | 客户端做什么 |
|---|---|---|
| 快(几秒答完,非流式) | 201 + Location 指向那条新消息 |
直接用结果 |
| ⭐ 边看边等(本板块主路径) | 200(第一帧一发就定死,05 章) | 逐帧渲染 |
| 用户等不了 / 批量 / 要重试 | ⭐ 202 + Location: /jobs/{jid} |
轮询 job,完成后去 result_url 取 |
⭐⭐ 11 章第三节那张「状态怎么让前端知道」的表(轮询 vs SSE),上游就是这里 —— 那张表回答的是「怎么把状态送过去」,而先得有个东西可以被查,那个东西就是 Location 指向的 job 资源。没有它,轮询没有地址可轮。
⚠️ 两个坑:① job 不是消息的替代品,是它的一个阶段 —— job 里要带 result_url 指回资源,否则前端只认识 job_id、不认识结果的位置。② 用户那条消息要先单独落库再入队 —— 6 章第六节已经立了这条(「用户消息先单独提交,LLM 失败就记 failed 状态」),⭐ 它在接口层的红利是:202 的响应里当场就能带上那条 user message 的 seq,前端立刻能渲染用户自己那句话。
# 一次对话既是资源又是长任务:用 202 + 一个 job 资源把两种建模接起来
import uuid
from fastapi import FastAPI, HTTPException, Response
from fastapi.testclient import TestClient
app = FastAPI()
MESSAGES = {"c1": []} # 会话资源(第 6 章两张表的内存版)
JOBS = {} # 任务资源(第 11 章 jobs 表的内存版)
@app.post("/conversations/{cid}/messages", status_code=202) # ⭐ 202 = 收到了,还没答应你会成功
def post_message(cid: str, body: dict, resp: Response):
if cid not in MESSAGES:
raise HTTPException(404, "no such conversation")
seq = len(MESSAGES[cid]) # ⭐ 用户那条消息先单独落库,202 里当场就能给出 seq
MESSAGES[cid].append({"seq": seq, "role": "user", "content": body["content"]})
jid = "j_" + uuid.uuid4().hex[:8]
JOBS[jid] = {"status": "queued", "cid": cid,
"result_url": f"/conversations/{cid}/messages"} # ⭐ 结果最终落回资源
resp.headers["Location"] = f"/jobs/{jid}" # ⭐ 202 必须说清"去哪儿看"
return {"job_id": jid, "user_message_seq": seq}
@app.get("/jobs/{jid}")
def get_job(jid: str):
j = JOBS.get(jid)
if j is None:
raise HTTPException(404, "no such job")
if j["status"] == "queued": # 这里假装 worker 刚好干完,真的在第 11 章
j["status"] = "done"
MESSAGES[j["cid"]].append({"seq": len(MESSAGES[j["cid"]]), "role": "assistant"})
return j
c = TestClient(app)
r = c.post("/conversations/c1/messages", json={"content": "总结这份年报"})
print(r.status_code, "| Location:", r.headers.get("location"), "|", r.json())
实跑:202 | Location: /jobs/j_c896f95d | {'job_id': 'j_c896f95d', 'user_message_seq': 0}(job id 每次不同)。⭐ 三样东西各解决一个前端立刻会遇到的问题:状态码是 202 不是 201(活还没干)、有 Location(知道去哪轮询)、body 里有 user_message_seq(立刻能渲染用户那句话)。
⭐ 一句话记住:202 的意思不是「异步」,是「我还没答应你这件事会成功」。 所以它必须配一个能被查的地址;给不出地址,就别用 202。
📋 六、错误响应:状态码定大方向,code 定细节
第 3 章已经把形状定死了(code 机器码 + message 人话 + trace_id),这里只补两条关系:① ⭐ 状态码全世界共用,code 是你自己的词表 —— prompt_too_long、quota_exhausted 不该编进状态码,⚠️ 更别自定义 4xx 码(比如返 460),中间层不认识它,一律当普通 4xx 处理。② ⭐ 同一个 code 必须永远对应同一个状态码;把这个映射写成一张表放进代码,别散在各个 raise 里。
| 场景 | 状态码 | code |
队列里 |
|---|---|---|---|
| prompt 超长 | 422 |
prompt_too_long |
死信 |
| 内容被安全策略拒绝 | 422 |
content_blocked |
死信(⚠️ 这是判断结果不是故障,04 章原话) |
| 用户发太快 | 429 + Retry-After |
rate_limited |
重试 |
| ⭐ 你的上游额度不够 | 503 + Retry-After |
upstream_unavailable |
重试 |
| job 已完成,不能取消 | 409 |
job_not_cancellable |
⚠️ 不重试,也不算失败 |
⭐ 倒数第二行是 03 章立的那条:用户超限 429,你自己的上游额度不够 503 —— 原样透传上游的 429,用户会以为是自己发太快了。⚠️ 一个 03 章没往下说的推论:429 的正当性建立在「等一会儿真的会好」上。
⭐ 判据:
Retry-After你写得出来吗? 写得出来(令牌桶天然算得出,12 章),那是速率限流,429名副其实。 ⚠️ 写不出来(用户这个月的配额花完了,不充值不会恢复),429就是在骗客户端等一个永远不来的时刻 —— 语义上属于403那一档,细节交给code(quota_exhausted)。 ⭐ 同样是「超限」,一个动作是等,一个动作是去付钱。
🔄 换个栈怎么对应
| 概念 | Python / FastAPI(本板块主栈) | Node(Express / Hono) | Go |
|---|---|---|---|
| 路由带路径参数 | @app.get("/conversations/{cid}") |
app.get('/conversations/:cid') |
🗓️ Go 1.22+ 标准库:mux.HandleFunc("GET /conversations/{cid}", h) |
| 指定状态码 | status_code=202 |
res.status(202) / c.json(x, 202) |
w.WriteHeader(http.StatusAccepted) |
设 Location 头 |
resp.headers["Location"] = ... |
res.location(url) |
w.Header().Set("Location", ...) |
| 抛带码的错误 | raise HTTPException(409, ...) |
抛 HTTPException / next(err) |
返回 error,自己映射成码 |
| ⚠️ 校验失败默认给哪个码 | ⭐ 422(Pydantic 自动) | 要自己定(Zod 失败你自己 catch,常写成 400) | 没有默认,全靠自己判 |
| 方法语义、状态码语义 | 三家一模一样 —— 它们属于 HTTP,不属于框架 |
⭐ 这张表要看出的是:只有倒数第二行三家真不同,而它恰恰是换栈时最容易被忽略的一行。 客户端如果按 422 分诊「参数不合法」,代码搬到 Node 就全掉进 400 分支 —— ⚠️ 而两个码都是 4xx、都进死信,粗粒度监控完全看不出来,只有细粒度的错误分类会静默失真。剩下几行的差别全是拼写差别。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 03c · 幂等与条件请求 | ⭐ 本章只说了「PUT 幂等、POST 不幂等」这个承诺;怎么让不幂等的 POST 也能安全重发整章在那边 |
| 03 · 后端骨架 | 本章第六节直接接它的 code / message / trace_id,以及「用户超限 429、上游额度不够 503」 |
| 04 · 调用层 | ⭐ 那张重试白名单是客户端视角,本章是服务端视角。两张表必须对得上 —— 你返的码,别人正是按那张表处置的 |
| 05 · 流式输出 | 唯一一处状态码用不了的地方 —— 所以本章这些码必须在第一个字之前全部用完 |
| 06 · 关系数据库 | 本章 URL 层级里的 conversations / messages 就是那两张表;⭐「用户消息先单独提交」在本章变成 202 响应里的 user_message_seq |
| 08 · 认证会话与多租户 | 401 / 403 / 404 的选择在那一章变成越权防御:本章给「返哪个码」的判据,那一章讲怎么从结构上保证不漏 |
| 11 · 长任务与队列 | ⭐⭐ 本章补的正是它那条「400/401 死信、429/500 重试」规则的生产端;它第三节那张「轮询 vs SSE」的表,上游就是本章第五节的 202 + job 资源 |
| 12 · 限流配额与成本护栏 | 429 的 Retry-After 怎么算。⭐ 本章反过来用它做判据:Retry-After 写不出来的「超限」本来就不该是 429 |
| 智能体工程 07 · 工具设计 | 08 · MCP | ⚠️ 看起来矛盾,其实不是:那两章说「一个工具应该对应人类的一个任务意图,而不是数据库的一张表或 REST API 的一个端点」,说的是给模型用的工具;本章说的是给客户端用的接口。⭐ 两者是上下游 —— Cloudflare 用两个工具覆盖约 2500 个 API 端点,前提正是那 2500 个端点存在且划得清楚 |
✅ 检查点
- 这一章补的是第 11 章那条重试/死信规则的哪一半?举一个「写错状态码,对面就做错决定」的例子。
- URL 里写动词(
POST /createConversation)跟「好不好看」无关的那个代价是什么? - 判断「该不该单独建一个资源」的判据是什么?
- 为什么说方法是承诺而不是规矩?
DELETE连发两次第二次该返什么、为什么? - 201 和 202 的差别是什么?202 必须配上什么才成立?
- 401 和 403 分错会造成什么具体症状?跨租户访问该返 404 还是 403,判据和代价各是什么?
- 409 为什么是 4xx 通则的例外?它在队列里该死信还是重试?
- 「用户配额用完了」该返 429 还是 403?本章给的判据是哪一句?
- 换到 Node 或 Go,本章哪一行会真的变?为什么这个变化在监控上看不出来?
👀 答案
- 补的是生产端 —— 服务端按什么判据把错误分进那几个码(11 章讲的是客户端拿到码之后怎么处置)。例子任选:「prompt 超长」返 500 会被按白名单重试 3 次,本该 0.2 秒的错拖成 8 秒、日志里还像上游在抖;「上游 502」翻成 400 会一次进死信,丢掉本该重试一次就好的活。
- 代价是方法全变成 POST,于是代理、CDN、浏览器、日志、限流器、重试器看到的都是一个不透明的 POST —— 能不能缓存、重发安不安全、是读还是写全推不出来。信息藏在 URL 字符串里,没有中间层会去读。
- 它有没有自己的生命周期:需不需要被单独查询、单独重试、单独取消。3 分钟的 PDF 总结有
queued / running / done、有查进度和取消的需求,所以必须有自己的 id;300 毫秒返回的没有中间状态,就只是一次POST。 - 因为客户端和中间层真的照着它行事,不问你一句 ——
GET上的删除会被预取、被 CDN 缓存住「成功」、被重试器自动重发,参数还进各层日志。DELETE第二次该返 204(实跑就是[204, 204]):它承诺幂等,终态一样就该给一样的答复;客户端要的是「终态对不对」,不该因为「上一次其实成功了」而收到错误。 - 201 = 已经成了、喏在这儿;202 = 排上了、成不成还不知道。 返 201 却把活丢进队列,客户端立刻
GET那个Location会拿到 404,还分不清是自己拿早了还是服务器搞砸了。⭐ 202 必须配一个能被查的地址(Location指向 job 资源);给不出地址就别用 202。 - 401 是「不知道你是谁」(换凭证可能就行 → 跳登录页),403 是「知道你是谁但不许」(换凭证也没用)。混用的症状是登录成功 → 又被跳回登录页 → 无限循环,而日志里全是成功的登录。跨租户判据:403 承认了这个 id 存在,id 可枚举时就成了探测接口,所以「存在与否本身敏感就返 404」。代价是自己排查时分不清「真没有」和「没权限」,解法是对外 404、日志里记真实原因和
trace_id。 - 因为 409 说的是「请求没问题,是资源现在的状态不允许」—— 等那个 job 跑完、那把锁放开,同样的请求可能就成功了,这是 4xx 里少数「过一会儿会好」的。所以队列里该退避重试(有上限),⚠️ 不是死信;一股脑塞进死信会丢掉一批只是撞上时序的任务。
- 判据是 ⭐ 「
Retry-After你写得出来吗」。写得出来(令牌桶算得出,12 章)→ 速率限流,429名副其实;写不出来(本月额度花完、不充值不恢复)→429是在骗客户端等一个永远不来的时刻,语义上属于403那一档,细节交给code(quota_exhausted)。⭐ 一个动作是等,一个是去付钱。 - 校验失败默认给哪个码:FastAPI / Pydantic 自动给 422,Node 里 Zod 要你自己 catch(常写成 400),Go 标准库没有默认。看不出来是因为 400 和 422 都是 4xx、都进死信,粗粒度监控完全一致,只有细粒度的错误分类会静默失真。其余几行三家一个字都不用改 —— 它们属于 HTTP 不属于框架。
🛑 可以停在这里
⚡ 走神救援
补的是第 11 章那条规则的生产端:11 章说「
400/401/内容被拒进死信,429/500/超时才重试」,04 章的白名单、12 章的Retry-After也建在上面 —— 但全板块从没讲过服务端该按什么判据往这几个码里分。⭐⭐ 主判据:每个状态码都在回答「这是谁的错、对面该不该再发一次」。 两个方向都疼:「prompt 超长」返 500 会被重试三次、把 0.2 秒的错拖成 8 秒还伪装成上游在抖;上游 502 翻成 400 会一次进死信,丢掉本该重试一次就好的活。 顺序:资源 → 方法 → 状态码。 ① 资源用名词不用动词,真代价是方法全变成 POST,中间层什么都推不出来。⭐ 该不该单独建资源,看它有没有自己的生命周期(要不要被单独查询、重试、取消)。② ⭐⭐ 方法是承诺不是规矩:GET安全+幂等(会被预取、缓存、自动重发),POST什么都不承诺,PUT/DELETE幂等,PATCH不保证。💀 把删除写成GET会被预取器扫光、被 CDN 缓存住「成功」、被重试器重发。实跑DELETE两次都是 204。生成天然不幂等,所以发消息就该是POST(怎么让它安全重发归 03c)。 ③ 状态码:4xx = 你别再这么发了,5xx = 我这边的问题你可以再试。⭐ 201 = 已经成了喏在这儿,202 = 排上了成不成还不知道。⭐ 401 = 换凭证可能就行(跳登录页),403 = 换凭证也没用,混用会造成登录无限循环、而日志里全是成功的登录。⭐⭐ 409 是 4xx 通则的例外(「资源现在的状态不允许」,等一会儿可能就成功),队列里该重试、不该死信。⭐ 跨租户该返 404 还是 403:403 承认了这个 id 存在,所以「存在与否本身敏感就返 404」,代价是自己排查分不清,解法是对外 404、日志记真实原因和trace_id。实跑五个码依次 401 422 404 403 409 200,⚠️ 判断顺序也是设计:认人 → 归属 → 权限 → 状态。⚠️ 流式下状态码在第一帧就定死成 200,所以这些码必须在第一个字之前全用完。 ④ ⭐⭐ AI 特有的真问题:一次对话既是资源(6 章 conversations/messages)又是长任务(11 章 jobs)。 只做消息拿不到进度、没法取消;只做 job 则不知道结果落在哪。答案是 202 + 一个 job 资源:202 +Location: /jobs/{jid}+ body 里带user_message_seq。⭐ 11 章那张「轮询 vs SSE」的表,上游就是这里 —— 先得有个能被查的地址轮询才有得轮。⭐ 202 不是「异步」,是「我还没答应你这件事会成功」。 ⑤ 错误响应接 03 章三件套:状态码定大方向、code定细节(⚠️ 别自定义 460 这种码),同一个code永远配同一个状态码。⭐ 给「用户超限 429、上游额度不够 503」加了个推论 —— 判据是「Retry-After你写得出来吗」:写不出来(本月额度花完)就更接近 403,一个动作是等,一个是去付钱。⑥ 换栈只有「校验失败默认返哪个码」三家真不同(FastAPI 自动 422、Node 常写 400、Go 没默认),⚠️ 它在监控上看不出来(都是 4xx、都进死信);方法和状态码语义三家一样,因为它们属于 HTTP,不属于框架。
下一节 👉 03c-幂等与条件请求.md