错误响应也是 API 契约:设计原理
一句话总结
区分业务异常和内部错误,并提供一致的问题响应(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 → 构造问题正文 → 让响应格式保持一致 → 接上业务异常处理器 → 连预料之外的错误也要隐藏。
每一步检查的不是函数或文件是否存在,而是实际的返回值、异常和状态变化。看过正确答案之后,请故意改动边界比较或清理代码,确认哪些测试会失败。请说明前面的测试为什么在下一步中依然保持有效,并写出一条本实验不能保证的生产条件。