🏠 总目录📚 本教程 03b · 接口的形状
📑 本页目录(点开跳转)

03b · 接口的形状:资源怎么划、方法怎么选、状态码怎么返

76 分钟 | ⭐ 第 11 章立了「400/401 直接进死信,429/500/超时才重试」—— 那条规则的另一半在这里


🎯 一句话

状态码不是给人看的装饰,是你对客户端下的一道指令:这个错该不该重试、这个请求还要不要再发。 后面好几章都在你返回的那个数字并据此行动,却没有一章讲你该怎么写它


🧩 一、先把洞指出来:一条规则只有一半

第 11 章第五节立了一条判据,第 16 章的验收项就是照它写的:

⚠️ 还要分清能不能重试400401、内容被安全策略拒绝,重试一万次也是同样结果,直接进死信;只有 429500、超时才值得退避重试。

第 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-KeyETag、条件请求)是下一章 03c 整章的事。

💀 最贵的一条:把会改状态的操作写成 GETGET /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 还是 40316 章那条验收项写的是「用 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_longquota_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 那一档,细节交给 codequota_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, ...) HTTPExceptionnext(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 · 限流配额与成本护栏 429Retry-After 怎么算。⭐ 本章反过来用它做判据:Retry-After 写不出来的「超限」本来就不该是 429
智能体工程 07 · 工具设计08 · MCP ⚠️ 看起来矛盾,其实不是:那两章说「一个工具应该对应人类的一个任务意图,而不是数据库的一张表或 REST API 的一个端点」,说的是给模型用的工具;本章说的是给客户端用的接口。⭐ 两者是上下游 —— Cloudflare 用两个工具覆盖约 2500 个 API 端点,前提正是那 2500 个端点存在且划得清楚

✅ 检查点

  1. 这一章补的是第 11 章那条重试/死信规则的哪一半?举一个「写错状态码,对面就做错决定」的例子。
  2. URL 里写动词(POST /createConversation)跟「好不好看」无关的那个代价是什么?
  3. 判断「该不该单独建一个资源」的判据是什么?
  4. 为什么说方法是承诺而不是规矩?DELETE 连发两次第二次该返什么、为什么?
  5. 201 和 202 的差别是什么?202 必须配上什么才成立?
  6. 401 和 403 分错会造成什么具体症状?跨租户访问该返 404 还是 403,判据和代价各是什么?
  7. 409 为什么是 4xx 通则的例外?它在队列里该死信还是重试?
  8. 「用户配额用完了」该返 429 还是 403?本章给的判据是哪一句?
  9. 换到 Node 或 Go,本章哪一行会真的变?为什么这个变化在监控上看不出来?
👀 答案
  1. 补的是生产端 —— 服务端按什么判据把错误分进那几个码(11 章讲的是客户端拿到码之后怎么处置)。例子任选:「prompt 超长」返 500 会被按白名单重试 3 次,本该 0.2 秒的错拖成 8 秒、日志里还像上游在抖;「上游 502」翻成 400 会一次进死信,丢掉本该重试一次就好的活
  2. 代价是方法全变成 POST,于是代理、CDN、浏览器、日志、限流器、重试器看到的都是一个不透明的 POST —— 能不能缓存、重发安不安全、是读还是写全推不出来。信息藏在 URL 字符串里,没有中间层会去读。
  3. 它有没有自己的生命周期:需不需要被单独查询、单独重试、单独取消。3 分钟的 PDF 总结有 queued / running / done、有查进度和取消的需求,所以必须有自己的 id;300 毫秒返回的没有中间状态,就只是一次 POST
  4. 因为客户端和中间层真的照着它行事,不问你一句 —— GET 上的删除会被预取、被 CDN 缓存住「成功」、被重试器自动重发,参数还进各层日志。DELETE 第二次该返 204(实跑就是 [204, 204]):它承诺幂等,终态一样就该给一样的答复;客户端要的是「终态对不对」,不该因为「上一次其实成功了」而收到错误。
  5. 201 = 已经成了、喏在这儿;202 = 排上了、成不成还不知道。 返 201 却把活丢进队列,客户端立刻 GET 那个 Location 会拿到 404,还分不清是自己拿早了还是服务器搞砸了。⭐ 202 必须配一个能被查的地址Location 指向 job 资源);给不出地址就别用 202。
  6. 401 是「不知道你是谁」(换凭证可能就行 → 跳登录页),403 是「知道你是谁但不许」(换凭证也没用)。混用的症状是登录成功 → 又被跳回登录页 → 无限循环,而日志里全是成功的登录。跨租户判据:403 承认了这个 id 存在,id 可枚举时就成了探测接口,所以「存在与否本身敏感就返 404」。代价是自己排查时分不清「真没有」和「没权限」,解法是对外 404、日志里记真实原因和 trace_id
  7. 因为 409 说的是「请求没问题,是资源现在的状态不允许」—— 等那个 job 跑完、那把锁放开,同样的请求可能就成功了,这是 4xx 里少数「过一会儿会好」的。所以队列里该退避重试(有上限),⚠️ 不是死信;一股脑塞进死信会丢掉一批只是撞上时序的任务。
  8. 判据是 ⭐ Retry-After 你写得出来吗」。写得出来(令牌桶算得出,12 章)→ 速率限流,429 名副其实;写不出来(本月额度花完、不充值不恢复)→ 429 是在骗客户端等一个永远不来的时刻,语义上属于 403 那一档,细节交给 codequota_exhausted)。⭐ 一个动作是等,一个是去付钱。
  9. 校验失败默认给哪个码: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

打卡记录保存在你的浏览器里,首页能看到总进度