3.3 幂等、分页与统一错误契约
3.1 立了资源,3.2 立了流式,但一个「能用的 API」和「能扛住真实流量的 API」之间,还差三件小事:重试不会重复建任务、错误有统一形状、列表能稳定翻页。这三件事都不显眼,却决定了客户端敢不敢重试、运维能不能排障。这一节把它们一次补齐。
本节把 ResearchCopilot 推进到「客户端可安全重试」:新增
Idempotency-Key幂等控制、code / message / details / request_id四字段统一错误体、以及游标分页,并用TestClient真跑三组请求。
3.3.1 重试是常态,幂等是刚需
网络请求会超时、会断线,客户端的标准应对是重试。但一个朴素的问题立刻出现:客户端发了 POST /sessions/{id}/runs,服务端其实收到了、run 也建好了,只是响应在回来的路上丢了。客户端超时后重试——于是同一个提问建出了两个 run,用户看到两份重复报告,后台白跑一次检索。
这就是幂等要解决的:同一个逻辑请求,无论发多少次,效果与发一次相同。GET / PUT / DELETE 天生幂等,麻烦的是 POST——它被设计成「每次调用都创建新资源」。解法是让客户端带一个 Idempotency-Key 头,服务端记住「这个 key 已经处理过」,重复请求直接返回首次的结果。
3.3.2 Idempotency-Key 的落地
核心是一张 key -> (请求体指纹, run_id) 的映射表。客户端生成一个唯一的 key(通常是 UUID),随请求发出;服务端收到后先查表:
import hashlib
from fastapi import Header
from fastapi.responses import JSONResponse
_idem: dict[str, tuple[str, str]] = {} # Idempotency-Key -> (body_hash, run_id)
@app.post("/sessions/{sid}/runs", status_code=201)
def create_run(sid: str, body: RunCreate, idempotency_key: str | None = Header(default=None)):
if sid not in _sessions:
raise ApiError(404, "session_not_found", "会话不存在", {"session_id": sid})
if idempotency_key:
body_hash = hashlib.sha256(body.model_dump_json().encode()).hexdigest()[:12]
if idempotency_key in _idem:
old_hash, old_rid = _idem[idempotency_key]
if old_hash != body_hash:
raise ApiError(409, "idempotency_conflict",
"同一 Idempotency-Key 携带了不同请求体",
{"idempotency_key": idempotency_key})
return JSONResponse(status_code=200, content=_runs[old_rid]) # 重放
rid = "r_" + uuid.uuid4().hex[:8]
_idem[idempotency_key] = (body_hash, rid)
else:
rid = "r_" + uuid.uuid4().hex[:8]
_runs[rid] = {"id": rid, "session_id": sid, "status": "queued",
"input": body.input, "created_at": now()}
return _runs[rid]
三个设计点值得留意:
- 记的是「请求体指纹」不是「请求体」。把 body 的 SHA-256 存下来,重复请求比对指纹即可;不存原文,既省空间又避免把用户输入留在幂等表里。
- 指纹不同就报 409。同一个 key 配不同 body 是客户端 bug(key 复用错了),必须显式冲突,不能「以第一次为准」静默吞掉。
- 重放返回 200 而非 201。首次创建是 201(资源诞生),重放是 200(资源早已存在)——状态码本身就在告诉客户端「这次没新建」。
3.3.3 真跑:重复 POST 只创建一个 run
用 TestClient 连发两次同样的请求,带上同一个 Idempotency-Key:
key = "idem-abc-001"
r1 = client.post(f"/sessions/{sid}/runs", json={"input": "对比 pgvector 与 Qdrant"},
headers={"Idempotency-Key": key})
r2 = client.post(f"/sessions/{sid}/runs", json={"input": "对比 pgvector 与 Qdrant"},
headers={"Idempotency-Key": key})
print("first :", r1.status_code, r1.json()["id"])
print("second:", r2.status_code, r2.json()["id"])
print("same id?", r1.json()["id"] == r2.json()["id"], " run 总数:", len(_runs))
# 同一 key 换 body
r3 = client.post(f"/sessions/{sid}/runs", json={"input": "换个问题"},
headers={"Idempotency-Key": key})
print("status:", r3.status_code, r3.json())
真实输出:
first : 201 r_d5b4e816
second: 200 r_d5b4e816
same id? True run 总数: 1
status: 409 {'code': 'idempotency_conflict', 'message': '同一 Idempotency-Key 携带了不同请求体', 'details': {'idempotency_key': 'idem-abc-001'}, 'request_id': 'req_8c8d11aa5a12'}
两次请求返回同一个 run id r_d5b4e816,且 run 总数 是 1——重复请求没有创建第二个 run。状态码从 201 变 200,如实反映「这次是重放」。换 body 后变成 409,客户端能立刻发现自己的 key 用错了。
3.3.4 统一错误响应体
错误最怕「每种错长得都不一样」。客户端要 if 一堆分支才能判断,日志里也拼不出模式。我们的解法是所有错误响应体统一四字段:
class ApiError(Exception):
def __init__(self, status: int, code: str, message: str, details: dict | None = None):
self.status = status
self.code = code
self.message = message
self.details = details or {}
@app.exception_handler(ApiError)
async def api_error_handler(request: Request, exc: ApiError) -> JSONResponse:
return JSONResponse(status_code=exc.status, content={
"code": exc.code, # 机器可判别的稳定标识
"message": exc.message, # 给人读的一句话
"details": exc.details, # 结构化补充,便于前端定位
"request_id": request.state.request_id, # 排障时的关联 id
})
| 字段 | 给谁看 | 说明 |
|---|---|---|
code | 机器 | 稳定不变的枚举,客户端按它分支,绝不改 |
message | 人 | 可读的一句话,允许随时润色 |
details | 前端 | 结构化上下文(哪个字段错了、哪个资源不存在) |
request_id | 运维 | 与日志关联,报障时用户一贴就能定位 |
request_id 由一段中间件在请求进入时生成,并回写进响应头,这样同一串 id 同时出现在响应体、响应头和日志里:
@app.middleware("http")
async def attach_request_id(request: Request, call_next):
request.state.request_id = "req_" + uuid.uuid4().hex[:12]
resp = await call_next(request)
resp.headers["X-Request-Id"] = request.state.request_id
return resp
真跑一个不存在的会话,看错误体长什么样:
status: 404 {'code': 'session_not_found', 'message': '会话不存在', 'details': {'session_id': 's_nope'}, 'request_id': 'req_e4deddd821ba'}
注意 code 是 session_not_found 这种稳定 snake_case,而不是把 HTTP 短语直接抄过来。客户端判断「会话没了」就比对 code == "session_not_found",永远不用去解析 message 的中文。
3.3.5 错误码表
把 ResearchCopilot 会用到的错误码一次列清,作为全卷的统一口径:
| HTTP | code | 场景 | 引入章节 |
|---|---|---|---|
| 400 | invalid_request | 参数格式错误 | 3.1 |
| 401 | unauthorized | 缺少或无效凭证 | 第 4 章 |
| 403 | forbidden | 越权访问他人 workspace | 第 4 章 |
| 404 | session_not_found | 会话不存在 | 3.1 |
| 404 | run_not_found | run 不存在 | 3.1 |
| 409 | idempotency_conflict | 同 key 配了不同请求体 | 3.3 |
| 429 | rate_limited | 超过 token 配额 | 第 6 章 |
| 500 | internal_error | 未捕获异常 | 全卷 |
| 503 | model_unavailable | 供应商不可用,降级中 | 第 2 章 |
说明:FastAPI 对 pydantic 校验失败会自动返回 422,其体形状与本表不同(是
detail数组)。要做到「全站错误一个形状」,需要注册一个RequestValidationError处理器把它改写成四字段体——这属于第 4 章中间件的活,本节先记下这个坑。
3.3.6 为什么用游标分页而不是 offset
列表接口迟早要分页。最直觉的写法是 ?offset=20&limit=20,但它在数据会增删的场景下会出问题:
| 维度 | offset 分页 | 游标分页 |
|---|---|---|
| 翻页参数 | offset + limit | cursor + limit |
| 数据插入时 | 后续页漏项或重项 | 从游标位置继续,不重不漏 |
| 深翻性能 | OFFSET 100000 要扫过前面全部行 | 走索引定位,与深度无关 |
| 能否跳到第 N 页 | 能 | 不能(只能顺序翻) |
| 适合 | 后台管理、数据基本静止 | 时间线、事件流、会话列表 |
Agent 的会话列表正是「边用边增」——你翻页时别人可能正建新会话。offset 分页下,新插入的行会把后续内容整体后移,导致第二页出现第一页看过的项,或者漏掉一项。游标分页把「下一页从哪个稳定锚点开始」编码进 cursor,插入多少都不影响已翻过的部分。代价是不能随机跳页,但对「无限滚动」这种交互来说,顺序翻页本来就够用。
3.3.7 真跑:游标翻页
游标用「上一页最后一条的 id」base64 编码而成,下一页从该 id 之后取:
import base64
def encode_cursor(sid: str) -> str:
return base64.urlsafe_b64encode(sid.encode()).decode()
@app.get("/sessions")
def list_sessions(limit: int = 2, cursor: str | None = None) -> dict:
ids = list(_sessions.keys())
start = 0
if cursor:
after = base64.urlsafe_b64decode(cursor.encode()).decode()
if after in ids:
start = ids.index(after) + 1
page = ids[start:start + limit]
has_more = len(ids) > start + limit
return {
"items": [_sessions[i] for i in page],
"next_cursor": encode_cursor(page[-1]) if has_more and page else None,
}
建 5 个会话后,limit=2 连翻三页的真实输出:
page1: ['研究课题 0', '研究课题 1'] next_cursor=c18zNzllYjQy...
page2: ['研究课题 2', '研究课题 3'] next_cursor=c19jZmNkODFi...
page3: ['研究课题 4'] next_cursor=None
三页正好覆盖 5 条,无重无漏;next_cursor 在最后一页变成 null,客户端据此停止翻页。「有没有下一页」由 next_cursor 是否为 null 决定,而不是去猜「本页是否满 limit」——后者在数据量恰好是 limit 整数倍时会多打一次空请求。
3.3.8 真实环境 / 真实模型下会怎样
三件事在真实部署里都会变重:
- 幂等表要有 TTL 且要落库。内存字典重启即丢,重试窗口一过就没意义;生产里
Idempotency-Key通常存 Redis(第 6 章)并设 24 小时过期,避免无限增长。 - 游标要编码复合键。单靠 id 排序在「同一秒创建多条」时不稳定,真实实现用
(created_at, id)复合游标,保证全序。 - 错误体要覆盖框架默认错误。pydantic 的 422、Starlette 的 405 都得改写进四字段体,否则「统一」只是口号。
本节验收清单:
- 同一
Idempotency-Key重复 POST 返回同一 run id,且 run 总数不增 - 同一 key 换 body 返回 409,
code == "idempotency_conflict" - 错误体恒为
code / message / details / request_id四字段 - 响应头带
X-Request-Id,与错误体里的request_id一致 - 游标翻页无重无漏,末页
next_cursor为 null
3.3.9 小结
POST默认不幂等,用Idempotency-Key+ 请求体指纹实现「重试只建一次」。- 指纹不同报 409;重放返回 200 而非 201,用状态码区分「新建」与「命中已有」。
- 错误体统一四字段:
code(机器)/message(人)/details(前端)/request_id(运维)。 - 游标分页在「边用边增」的列表上不重不漏,且深翻性能恒定;代价是不能跳页。
- 幂等表要 TTL、游标要复合键、框架默认错误要改写——真实化时逐条补齐。
第 3 章到此收束:ResearchCopilot 有了资源模型、流式接口和可重试的契约。但这一切对所有人开放——任何人都能调别人的会话。下一节 4.1 认证:JWT 与 API Key 中间件 会给这张脸加上身份。想回看流式接口,翻 3.2 流式接口:SSE 推送事件 。
阅读导航:上一节:3.2 流式接口:SSE 推送事件 · 下一节:4.1 认证:JWT 与 API Key 中间件 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。