2018 年做一个客户列表时,联调持续了将近一周。接口文档里写着 status: number,前端理解成 HTTP 风格的成功状态,服务端实际返回业务枚举;分页一会儿从 0 开始,一会儿从 1 开始;空列表有时是 [],有时是 null。双方每天都在“修一个字段”,第二天又出现新的解释差异。

我以前把接口文档理解成字段说明。那次以后才明白,契约还要描述请求能否重复、数据顺序是否稳定、失败是否可重试,以及新旧客户端同时存在时怎么兼容。

API 契约由请求语义、响应结构、失败与兼容策略组成

图 1:字段只是表面,真正决定系统能否协作的是双方都能验证的行为。

先把模糊词换成可执行定义

“分页正常”“失败返回错误”“字段可能为空”都无法直接测试。我们把列表接口写成更具体的约定:

GET /api/customers
query:
  cursor: string | omitted
  limit: integer, 1..100, default 20
response:
  items: Customer[]
  nextCursor: string | null
invariants:
  - items 始终是数组
  - 顺序固定为 createdAt DESC, id DESC
  - nextCursor 为 null 表示没有下一页
  - 相同 cursor 与数据快照返回相同边界

相比 page=1,游标在数据持续新增时更稳定,但它也带来一个约束:排序字段必须稳定并包含唯一兜底键。这个细节应该进入契约,而不是藏在服务端实现里。

错误码要指向恢复动作

早期接口失败统一返回 { code: 500, message: "error" }。前端无法判断是让用户改输入、重新登录,还是稍后重试。我们按恢复责任划分:

HTTP 业务码 含义 前端动作
400 INVALID_FILTER 筛选表达式非法 定位字段并让用户修改
401 SESSION_EXPIRED 会话失效 刷新凭证或重新登录
403 CUSTOMER_FORBIDDEN 无资源权限 不重试,展示权限说明
409 VERSION_CONFLICT 编辑版本落后 拉取最新数据并提示合并
429 RATE_LIMITED 超过频率 Retry-After 延迟
500 INTERNAL_ERROR 未知服务端错误 展示 requestId,有限重试

稳定的业务码是程序分支依据,message 只是给人看的补充,不能反过来解析字符串。

创建接口必须讨论重复请求

网络超时后,客户端不知道请求是没到服务端,还是已经成功但响应丢失。直接重试可能创建重复客户。契约因此加入幂等键:

POST /api/customers
Idempotency-Key: 7d62ad0d-5d36-4ae5-a58d-80233f04ecbb

服务端在同一调用方范围内保存 key、请求摘要和首次响应。相同 key、相同请求返回原结果;相同 key、不同请求返回冲突。这比前端禁用按钮更接近业务边界。

兼容不是永远保留旧字段

字段重命名时,我们曾经同时返回 customerNamename,却没有删除日期,最终两套字段长期存在。后来每次兼容都写四件事:引入版本、旧客户端比例、迁移方式和移除条件。

兼容项: response.name -> response.customerName
开始版本: API 2018-12-15
客户端迁移: Web build >= 1842
观测: 旧字段读取量
移除条件: 连续 14 天旧字段读取为 0

用示例和测试共享同一份事实

文档容易过期。我们把契约示例放进仓库,前端用它做解析测试,服务端用它验证响应,联调环境再跑一次真实请求。即使当时还没有完整 OpenAPI 工具链,这种“同一份样例被双方执行”已经显著减少口头解释。

我也开始在接口变更评审里问:旧调用方会怎样;失败后谁负责恢复;重试是否安全;日志如何把前后端请求串起来。接口契约的价值不是让文档更正式,而是把跨团队猜测变成可以提前失败的测试。