TT Lab
开始
学习 学习路径 课程

FastAPI — 类型就是契约

错误响应也是 API 契约:设计原理

在 TT Lab 中继续学习

一句话总结

区分业务异常和内部错误,并提供一致的问题响应(Problem Details)。

为什么需要它

客户端发生错误时,一直在用正则表达式解析 detail 字符串。有一天句子改了,库存不足也被当成了支付重试来处理。在另一条路径上,异常字符串被原样公开,SQL 和内部路径因此泄露了出去。错误和成功响应一样,需要明确的契约。

工作原理

给业务错误贴上 missing、conflict、invalid 这样稳定的 code。把 code 转换为 HTTP 状态和公开的文案,但不要把原始异常信息复制到公开的正文里。请求 id 要限制长度和字符集,并把同样的值同时放进正文和响应头。预料之中的业务错误,以及预料之外的 RuntimeError,都用真实的 TestClient 请求来确认。

업무 예외 → code → 상태·공개 메시지
내부 예외 → 고정 500 → 세부 내용 숨김
검증한 요청 id ─────────→ 헤더와 본문

阅读契约并预测失败的工作表

下面不是让你把整个实现背下来的答案,而是分步骤的代码评审。每个改动片段都故意破坏了契约。请注意,改动之后,正常用例仍然可能通过。在运行之前,先预测观测哪些输入、异常和状态才能看出差异,实现之后,再把这个预测与结果进行比较。

1. 给业务异常留下 code

DomainError(code, message) 是 Exception 的子类,把 code 保存在 .code 中。str(异常) 就是 message。

判断依据:把供机器判断的 code 与供人查看的内部信息分开。

待评审的错误改动片段:

self.code = "invalid"

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

2. 把 code 映射为状态

status_for(code) 中,missing=404,conflict=409,invalid=422,其余=500。

判断依据:不要把未知的 code 当作成功处理。

待评审的错误改动片段:

.get(code, 200)

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

3. 固定公开的文案

public_message(code) 中,missing='Resource not found',conflict='State conflict',invalid='Invalid request',其余='Internal error'。

判断依据:即使异常字符串里含有 DB 地址或内部路径,也不公开。

待评审的错误改动片段:

"invalid":"Internal error"

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

4. 限制请求 id

request_id(value) 在值是只由 ASCII 字母、数字、下划线、连字符组成的 1–32 个字符的字符串时,原样返回,否则返回 'untracked'。

判断依据:为了不回显任意头部,要同时限制长度和字符集。

待评审的错误改动片段:

{1,64}

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

5. 构造问题正文

problem(code, rid) 是只含 type='urn:labhub:problem:'+code、title 和 detail=public_message(code)、status=status_for(code)、request_id=request_id(rid) 的字典。

判断依据:如果正文和 HTTP 状态互不一致,客户端就无法决定该相信哪个值。

待评审的错误改动片段:

"status":500

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

6. 让响应格式保持一致

response_for(code, rid) 是一个 JSONResponse:以 problem 为正文,以 status_for 为状态,以 application/problem+json 为 media_type,以规范化后的 rid 作为 X-Request-ID。

判断依据:直接返回简单的字典,可能让错误也变成 200。

待评审的错误改动片段:

media_type="application/json"

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

7. 接上业务异常处理器

install_handlers(app) 注册 DomainError 的处理器。它读取头 X-Request-ID,并针对 exc.code 返回 response_for。exc 的 message 不放进响应。

判断依据:不要把捕获异常的位置分散开,而要放在应用的公共边界上。

待评审的错误改动片段:

response_for("invalid", request.headers.get("x-request-id"))

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

8. 连预料之外的错误也要隐藏

create_app() 安装处理器,并在 GET /fail/{code} 中抛出 DomainError(code, 内部句子)。不过当 code=boom 时,抛出 RuntimeError。RuntimeError 的处理器返回 code=internal 的固定 500 问题响应。

判断依据:在测试中关闭异常的重新抛出,确认真实的 500 响应的字节。

待评审的错误改动片段:

response_for("invalid", request.headers.get("x-request-id"))

请与包含该片段的函数的公开契约对照。如果仅凭一个成功用例无法区分,就把应被拒绝的输入或失败之后的状态选作观测对象。

在现场相遇的样子

本实验的问题响应,是一个带有 type、title、status、detail、request_id 的教学用契约。它并不声称支持通用的国际化和完整的标准一致性。请求 id 是用于追踪的字符串,不是认证手段,在生产日志中也不应该把机密值原样记录下来。

下一项实验要做什么

八个步骤会连成一个可运行的成果。给业务异常留下 code → 把 code 映射为状态 → 固定公开的文案 → 限制请求 id → 构造问题正文 → 让响应格式保持一致 → 接上业务异常处理器 → 连预料之外的错误也要隐藏。

每一步检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,请故意改动边界比较或清理代码,确认哪些测试会失败。请说明前面的测试为什么在下一步中依然保持有效,并写出一条本实验不能保证的生产条件。