错误处理
错误是一等资源类型,不是成功响应的一个变体。它有自己的 media type、自己的 schema、自己的稳定标识,和成功响应在类型系统里可判别。
形状
所有 4xx / 5xx 响应都是 RFC 9457 application/problem+json:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
X-Request-ID: req_01JBQ7X4M2K9P3W5T8ZVN6HRDC
{
"type": "https://developer.nextmoe.dev/problems/platform/unknown-include",
"title": "Unknown include",
"status": 400,
"code": "UNKNOWN_INCLUDE",
"detail": "include= received a token that is not in this collection's vocabulary: nope",
"instance": "/v2/catalog/works?include=tags,nope",
"request_id": "req_01JBQ7X4M2K9P3W5T8ZVN6HRDC",
"errors": [
{ "parameter": "include", "reason": "UNKNOWN_VALUE", "detail": "nope" }
]
}
| 字段 | 恒在 | 说明 |
|---|---|---|
type |
是 | 稳定的 problem type URI,解析到 本站的错误码页 |
title |
是 | 这个类型的固定英文短语,不随请求变化 |
status |
是 | 与状态行一致的 HTTP 状态码 |
code |
是 | 顶层稳定标识,封闭注册表里的 UPPER_SNAKE 字符串 |
detail |
是 | 英文、随请求变化的说明。没什么可补充时是空串。不要拿它做分支 |
instance |
是 | 出错的路径与查询串 |
request_id |
是 | req_ + 26 位 ULID,与 X-Request-ID 同值。报问题时请带上 |
errors |
是 | 字段级失败明细。不是字段级错误时是 [],永远不是 null |
object / current_id |
否 | 仅 ENTITY_MERGED 时出现 |
两层标识,两个注册表
顶层用 code,errors[] 里用 reason——故意不同名。两个层级用同一个键名,会让「读错嵌套层」这个最常见的消费端 bug 拿到一个看起来合理的值,而不是 undefined。两份注册表的成员集合互不重叠。
code:这次请求整体为什么失败。例如UNKNOWN_INCLUDE、SCOPE_REQUIRED、RATE_LIMITED。reason:某个具体字段为什么不合格。封闭的一小套:REQUIRED、INVALID_FORMAT、OUT_OF_RANGE、TOO_LONG、TOO_SHORT、TOO_MANY_ITEMS、DUPLICATE_ITEM、UNKNOWN_VALUE、NOT_ALLOWED_VALUE、UNKNOWN_REFERENCE、IMMUTABLE、INCONSISTENT_WITH。
errors[] 的每一项恰好带 pointer、parameter、header 三者之一,指出出问题的位置:pointer 是请求体里的 JSON Pointer(RFC 6901),parameter 是查询或路径参数名,header 是请求头名。
怎么分支
- 先看 HTTP
status。 这是唯一保证你永远认得的东西。 - 再看
code。 认得就走专门分支。 - 认不得就按
status兜底。 这是客户端契约的第三条:我们会往注册表里加新的code,你的客户端不能因此崩掉。
不要拿 detail 或 title 做分支——detail 随请求变化,两者都是英文散文。
按状态码的处置
| 状态 | 典型 code |
该怎么办 |
|---|---|---|
400 |
INVALID_PARAMETER UNKNOWN_INCLUDE UNKNOWN_SORT LIMIT_TOO_LARGE TOO_MANY_IDS INVALID_CURSOR |
你的请求有 bug。不要重试,修代码 |
401 |
MISSING_CREDENTIAL INVALID_CREDENTIAL |
带上凭据;用户令牌则刷新一次再试 |
403 |
SCOPE_REQUIRED USER_IDENTITY_REQUIRED PERMISSION_REQUIRED TENANT_MISMATCH |
换凭据或补 scope。重试同一请求没有意义 |
404 |
NOT_FOUND ENTITY_MERGED |
ENTITY_MERGED 时读 current_id 重指本地行;其余当作不存在 |
405 |
METHOD_NOT_ALLOWED |
路径在,方法不在 |
409 |
ALREADY_EXISTS IDEMPOTENCY_KEY_REUSED INVALID_STATE_TRANSITION |
先读当前状态再决定,不要盲目重试 |
410 |
GONE |
这个 URL 永久退役了。v1 的六个前缀都在这里 |
412 / 428 |
PRECONDITION_FAILED / PRECONDITION_REQUIRED |
重新 GET 拿最新 ETag,带上 If-Match 再写 |
429 |
RATE_LIMITED QUOTA_EXCEEDED |
按 Retry-After 等待。见 限流与配额 |
5xx |
INTERNAL_ERROR SERVICE_UNAVAILABLE |
指数退避 + 抖动重试;连续失败就降级到本地缓存 |
ENTITY_MERGED
被合并掉的实体不 301。请求它会拿到 404 + code: ENTITY_MERGED,body 里带 object 与 current_id,响应头带 Link rel="canonical":
{
"type": "https://developer.nextmoe.dev/problems/catalog/entity-merged",
"status": 404,
"code": "ENTITY_MERGED",
"object": "work",
"current_id": "207379",
…
}
之所以不是重定向:静默跟随会让你以为拿到的还是原来那条记录,本地库里的旧 id 也永远不会被修正。批量对账读 /v2/catalog/redirects。
完整注册表
每个 type URI 都能在本站打开,写着它的状态码、含义和触发条件。