客户不会读你的源码,只会读你的契约。
一、同一个错误,三个端点给了三种形状
林澈做了一个发票识别接口,三个端点在上线两个月后陆续被同一家客户接入。某天客户的工程师发来一封邮件,附了三段截图:第一个端点出错时返回 {"error":"..."},第二个返回 {"message":"..."},第三个在网关超时时直接吐了一段纯文本 500。
以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。
客户付出的代价。 那位工程师写了三套解析:先判断有没有 error 字段,再判断有没有 message 字段,最后兜底把响应体整个打印出来。更麻烦的是重试逻辑——前两种能读到错误类型,第三种只能靠状态码猜。他说了一句让林澈记了很久的话:「我不是在集成你的 API,我是在逆向它。」
问题不在实现,在契约缺位。 三个端点由不同时间写成,错误处理各自顺手,谁都没有错,但合在一起就没有可预期的形状。客户不会关心你的代码怎么组织,只会把观察到的行为当成规则,然后围绕这些规则写代码。
错误形状是契约的一部分。 状态码、错误码、字段名、是否可重试、有没有请求标识,这些一旦被客户依赖,改动就和改端点路径一样昂贵。把它们写进契约并当成兼容性边界,是本文的起点:先有统一形状,再谈文档与工具。
一份契约能省掉三类沟通。 客户问「这个字段一定会有吗」,看 schema;问「失败了怎么重试」,看错误表;问「下一页怎么拿」,看分页约定。三类问题都能指向同一个文件时,支持成本会明显下降,而这正是后面几节所有约定的实际回报。
先写错误表,再写端点。 错误表是一条条具体场景:金额为负、发票已支付、密钥过期、触发限流。把场景先列出来,端点的输入校验与状态码就有了落点;反过来先写端点再补错误,必然会漏掉分支,最后靠客户报障补齐。
二、OpenAPI 作单一事实源
契约的价值不在于生成一份好看的文档,而在于让「实现、文档、SDK、测试」四者指向同一个文件。
端点命名。 用名词复数加层级表达资源,动作交给 HTTP 方法。集合用 /invoices,单条用 /invoices/{invoice_id},子资源最多两层。避免在路径里出现动词,也不要把版本号塞进每个路径,版本放在前缀统一处理。
字段命名二选一并写死。 snake_case 或 camelCase 都可以,混用不行。混用会让客户在同一个响应里看到 created_at 和 requestId,然后每一个字段都要单独确认。选定之后写进 lint 规则,让不一致在提交时失败。
必填与可空是两个维度。 必填表示请求必须带、响应一定给;可空表示值可以为 null。把「可能没有」和「一定有但值是空」分开,客户才能安全地做判空。新增字段默认可选,删除字段视为破坏性变更。
时间与 ID 形态。 时间统一用 RFC 3339 带时区的字符串,不要用本地时间戳;ID 统一用带前缀的字符串,例如 inv_ 加随机串,避免客户把它当整数自增来处理。这两条写进契约的示例与描述里,比写在文档正文更有效。
契约先行与评审清单。 新端点先改 OpenAPI,再写实现。评审时只看四件事:路径与方法是否符合命名规则、请求与响应是否都有 schema、错误响应是否引用了统一信封、示例是否能被复制粘贴跑通。四条都过,才允许合并。
契约覆盖率。 对外端点必须 100% 出现在契约里,内部端点可以例外但要标注。覆盖率低于 100% 时,先补契约再补功能,否则文档会永久落后于实现。
示例比描述更有约束力。 同一个字段写「创建时间」几乎不传递信息,写一个带时区的完整示例才能让客户知道格式。每个端点至少给一个成功示例与一个失败示例,示例要通过校验工具,不能手写一段看起来对的字符串。
破坏性变更的判断标准。 删除字段、收紧取值范围、把可选改成必填、改变错误码语义,这四类都属于破坏性变更,必须走版本或迁移窗口;新增可选字段、新增错误码、放宽校验则通常是兼容的。
三、统一错误信封与状态码
错误信封只定义一次,所有端点复用。最小字段集是:稳定的错误码、人类可读的消息、请求标识、可选的字段级细节。
| 状态码 | 语义 | 典型场景 | 可重试性 |
|---|---|---|---|
| 400 | 请求格式错误 | JSON 解析失败、缺必填字段 | 不可重试,需改请求 |
| 401 | 未认证 | 缺少密钥、密钥无效或过期 | 不可重试,需换凭证 |
| 403 | 已认证但无权限 | 密钥有效但无权访问该资源 | 不可重试,需申请权限 |
| 404 | 资源不存在 | ID 不存在,或对调用方不可见 | 不可重试 |
| 409 | 状态冲突 | 重复创建、并发修改同一资源 | 视场景,通常需先查询再决定 |
| 422 | 语义校验失败 | 字段格式正确但业务规则不通过 | 不可重试,需改数据 |
| 429 | 触发限流 | 超出配额或速率限制 | 可重试,按 Retry-After 退避 |
| 5xx | 服务端问题 | 未捕获异常、依赖超时 | 可重试,需指数退避并有上限 |
错误码要稳定、可检索。 用大写加下划线,例如 invoice_already_paid,不要用数字或自然语言句子。错误码一旦发布就不要改语义,需要区分新情况时新增一个码,而不是复用旧码。
必须带请求标识。 每个响应都返回 request_id,错误响应里也带上。客户报障时给出这个值,你就能在日志里定位到那一次调用。没有它,排查会退化成猜时间窗口。
区分用户可修复与服务端问题。 4xx 是客户能改的,消息里要说清楚改哪里;5xx 是你的事,消息里不要暴露堆栈与内部主机名。把这两类混在一起,客户会为你的故障反复改自己的代码。
429 要给出退避信号。 除了状态码,响应头里给出可重试的秒数,正文里说明是速率限制还是配额用尽。客户据此写退避,才不会用固定间隔猛打,把你的限流器变成压测器。
不要把校验失败塞进 400。 请求能解析、字段齐全,只是业务规则不通过,用 422 更准确。400 留给格式层面的错误,客户才能区分「代码写错了」和「数据不符合规则」,两者的修复路径完全不同。
四、分页、过滤、排序与幂等键
列表端点是最容易被低估的部分。分页方式一旦被客户依赖,中途更换成本极高。
| 维度 | 游标分页 | 偏移分页 |
|---|---|---|
| 实现复杂度 | 较高,需要稳定排序键 | 低,直接换算 limit 与 offset |
| 深页性能 | 稳定,不随页数退化 | 随偏移增大而变慢 |
| 数据变动时 | 不重复、不跳项 | 可能重复或漏项 |
| 可跳页 | 不支持 | 支持 |
| 适合场景 | 时间序列、日志、大结果集 | 后台表格、小结果集、需要页码 |
| 参数形态 | 不透明 cursor 加 limit | offset 加 limit |
默认选游标。 对外 API 的结果集通常只增不减,客户也更关心「拿全」而不是「跳到第 37 页」。返回 next_cursor 与 has_more,游标本身不透明,客户不应解析其内容。
过滤参数用白名单。 只允许契约里列出的字段参与过滤,未知参数直接 400 而不是静默忽略。静默忽略会让客户以为自己筛过了,拿到全量数据后才发现问题。
排序字段限制在已建索引的列。 契约里列出允许排序的字段与方向,其他组合返回 400。允许任意字段排序,等于把数据库的慢查询入口开放给外部。
幂等键的适用范围与保留期。 只对创建类与扣费类请求要求 Idempotency-Key,查询类不需要。保留期至少覆盖客户的重试窗口,常见做法是 24 小时;同一键重复提交返回首次结果,而不是报错。键的作用域绑定到账号,不绑定到路径。
游标要能失效。 游标里编码排序键与过期时间,服务端校验不通过时返回 400 并提示重新从头拉取。永久有效的游标会在数据删除后指向不存在的记录,客户只能看到中间断掉的一页。
幂等键不能替代业务去重。 它只保证同一次重试不重复执行,不保证两个不同键指向同一笔业务。真正的重复下单要靠业务唯一约束兜底,否则客户换个键重试,你依然会创建出第二张发票。
五、行动清单与复算验收
先统一信封,再补端点。 如果现有端点已经形状各异,不要一次性重写。先定信封与状态码表,新端点必须遵守,旧端点按调用量从高到低逐个迁移,并在文档里标注迁移状态。
把契约当成代码来评审。 每次改动都走同一条检查路径:命名规则、必填与可空、示例可跑、错误响应引用统一信封。人工评审容易漏,能让 lint 拦下的就不要靠人记。
迁移期允许两种形状并存。 旧端点保留一段时间的旧格式,同时在响应头里给出弃用时间,新端点直接使用新信封。用一个可观测的截止日期收口,比一次性切换安全。
契约的维护成本要算进固定成本。 每加一个端点,就多一份 schema、示例与测试要跟着版本走。端点数量不是越多越好,先问这个端点是否有人明确要用,再决定要不要写进契约。
- 把全部对外端点列成清单,标注是否已写入 OpenAPI。
- 选定 snake_case 或 camelCase,并加一条 lint 规则禁止混用。
- 为每个端点补齐请求与响应 schema,示例必须能直接跑通。
- 定义统一错误信封,字段包含错误码、消息、请求标识与细节。
- 按 400/401/403/404/409/422/429/5xx 逐条写明语义与可重试性。
- 列表端点默认改用游标分页,并返回
next_cursor与has_more。 - 过滤与排序改为白名单,未知参数返回 400。
- 对创建类请求启用
Idempotency-Key,保留期写进文档。
验收标准:契约覆盖率达到 100%,错误码重复率为 0,且任取一个 4xx 响应都能在不看源码的情况下判断该改哪里。
契约覆盖率 = 已写入 OpenAPI 的对外端点 ÷ 对外端点总数
示例:48 ÷ 52 = 92.3%,GA 前目标 100%
错误码重复率 = 语义相同但错误码不同的组数 ÷ 错误码总数
目标:重复率 = 0,且每个错误码在文档中恰好出现一次
使用边界与实操验收
本文只讨论对外契约的形状与验收口径,不比较协议栈,也不展开代码生成工具链。它假设接口已经决定要做,正处在「怎么把规则写清楚」的阶段。
- API设计实战:REST、gRPC与GraphQL的选型、规范与版本管理 :它讲协议选型与技术规范,本文不选协议栈,只定对外契约。
- API契约与版本治理:OpenAPI/Protobuf契约管理、兼容性检查与SDK自动生成 :它讲契约治理工具链与自动生成,本文讲契约背后的判断与验收。
实操验收按三步走:先做一次「盲接」测试,找一个没看过你代码的人,只给契约与示例,看能否在半小时内完成一次成功调用与一次错误处理;再抽查五个历史错误响应,确认都能映射到统一信封;最后用上面的复算块算一遍覆盖率与重复率,两个数都达标才算收口。
本文不覆盖鉴权与密钥管理。 错误表里的 401 与 403 只定义语义,不涉及签发、轮换与泄露响应,那属于另一条产品线的工作,会在后续文章单独讨论。
关于版本策略。 本文只给出兼容性判断,不展开 URL 版本、请求头版本与日期版本三种方案的取舍。选哪一种都可以,关键是同一个项目里只选一种,并把它写进契约。
关键参考与核验范围
核验日期:2026-10-25。以下链接为公开规范入口,用于核对字段语义与错误格式,本文未逐条引用其正文。
- OpenAPI 规范 :用于核对路径、schema 与示例的描述能力。
- RFC 9457 Problem Details :用于核对错误响应中类型、标题与详情的字段命名。
引用口径。 本文借用这两份文档的概念框架,但错误信封的具体字段由你自己定义,不必与 Problem Details 完全一致;一旦选定,就对客户保持稳定。
为什么选这两份。 一份定义契约怎么写,一份定义错误长什么样,正好覆盖本文的两条主线。两者都不是强制的行业标准,但都是公开、稳定、可长期引用的入口。
核验范围。 核验的是概念是否仍然通用,不覆盖版本差异。两份文档都会修订,实现前请以打开页面时的版本为准。
系列导航
- 上一篇:要不要做 API:三类买家、收入结构与退出条件
- 下一篇:API key 管理:签发、最小权限、轮换与泄露响应
- 回到目录:一个人把 API 做成产品:对外接口、开发者体验与支持
- 相关阅读:API设计实战:REST、gRPC与GraphQL的选型、规范与版本管理 、API契约与版本治理:OpenAPI/Protobuf契约管理、兼容性检查与SDK自动生成
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。