RealWorld API 错误处理契约详解:422 / 401 / 403 / 404 响应格式与 Hurl 测试实证
RealWorld("The mother of all demo apps")为所有后端实现规定了统一的错误响应契约:当请求校验失败时返回 422 及固定结构的 errors 对象,认证缺失返回 401,越权操作返回 403,资源不存在返回 404。本文以 error-handling.md 为核心骨架,结合 OpenAPI 规范 与仓库中的 Hurl 测试套件(specs/api/hurl/),完整拆解每一类错误码的响应体结构、错误键的命名规则,以及可直接复制运行的端到端验证脚本,帮助你在实现或校验任意后端时精确对齐这份跨技术栈契约。
错误响应体:errors 对象的统一格式
规范原文给出的核心约定是:任何请求只要有一项校验失败,就应返回 HTTP 422,并且错误体采用如下格式:
{
"errors":{
"body": [
"can't be empty"
]
}
}
其结构在 OpenAPI 规范 中被固化为 GenericErrorModel(约第 637–647 行):
GenericErrorModel:
required:
- errors
type: object
properties:
errors:
type: object
additionalProperties:
type: array
items:
type: string
从源码结构看,这份模型可以归纳出三条规则:
- 顶层只有一个键
errors,且它是必需字段; errors是一个对象,键为出错字段的名称(如body、title、token、article),值统一是字符串数组——同一字段可以有多个错误信息;- 错误消息本身是纯字符串,由实现方给出,契约只约束"字段名 + 数组"这一外层形状。
这里有一个值得注意的细节:规范文档示例中的消息文案是 "can't be empty",而仓库中全部 Hurl 测试断言的实际文案是 "can't be blank"(例如 errors_auth.hurl 中 jsonpath "$.errors.username[0]" == "can't be blank")。backend 文档的 Introduction 明确声明:"These pages summarize the contract in prose, but the OpenAPI spec and the Hurl suite are what actually define it. Where the prose and the tests disagree, the tests win"——即测试套件与 OpenAPI 是契约的最终事实来源。因此实现方应以 Hurl 断言中的消息文案(can't be blank、is missing、not found、forbidden、has already been taken、invalid)为准。
状态码总览:422、401、403、404 及 409
规范文档定义了四类错误状态码,配合 Hurl 错误测试套件 可归纳为如下对照表:
| 状态码 | 语义 | 触发条件 | 典型错误键 | 实测断言文案 |
|---|---|---|---|---|
| 422 | 校验失败 | 请求体字段为空、缺必填项、违反取值规则 | 字段名(username/email/password/title/description/body) |
can't be blank |
| 401 | Unauthorized | 需要认证但未提供 token | token |
is missing |
| 403 | Forbidden | 请求合法但用户无权操作该资源 | 资源类型(article/comment) |
forbidden |
| 404 | Not Found | 按 slug/用户名/ID 找不到资源 | 资源类型(article/profile/comment) |
not found |
| 409 | Conflict | 唯一性冲突(注册时用户名/邮箱已被占用) | 冲突字段(username/email) |
has already been taken |
其中 409 一栏来自测试证据:errors_auth.hurl 第 53–77 行对重复用户名、重复邮箱的注册请求分别断言 HTTP 409 与 "has already been taken";OpenAPI 规范 中也定义了 ConflictError 响应(示例 errors: {username: ["has already been taken"]})。这说明错误体格式(errors 对象 + 字符串数组)是所有错误状态码共用的,不只是 422。
401 Unauthorized:token 缺失的统一拒绝方式
规范定义:"401 for Unauthorized requests, when a request requires authentication but it isn't provided"。契约要求错误键为 token、消息为 is missing,即响应体形如:
{ "errors": { "token": ["is missing"] } }
Hurl 套件对这条规则做了穷举式覆盖。errors_articles.hurl 验证了所有需要认证的 articles 端点在缺 token 时都必须返回 401 + errors.token[0] == "is missing",包括:
POST /api/articles(创建文章,第 1–12 行)PUT /api/articles/{slug}(更新,第 20–29 行)DELETE /api/articles/{slug}(删除,第 31–35 行)GET /api/articles/feed(信息流,第 37–41 行)POST /api/articles/{slug}/favorite与DELETE .../favorite(收藏/取消收藏,第 43–53 行)
同样的断言也出现在 errors_comments.hurl(评论的创建与删除,第 1–16 行)、errors_profiles.hurl(关注/取消关注,第 7–17 行)以及 errors_auth.hurl 中的 GET /api/user 与 PUT /api/user(第 115–130 行)。
一个容易踩坑的对照:密码错误不返回 422,而是 401。errors_auth.hurl 第 103–113 行对 POST /api/users/login 传入错误密码的请求断言 HTTP 401 且 errors.credentials[0] == "invalid"。也就是说,"凭证错误"被归类为认证失败(401 + credentials 键),而"字段为空"才走校验失败路径(422 + 字段键)。
403 Forbidden:有权限体系内的越权操作
规范定义:"403 for Forbidden requests, when a request may be valid but the user doesn't have permissions to perform the action"。与 401 的关键区别是:请求本身合法(token 有效、资源存在),但当前用户不是资源所有者。
错误键的规则在 OpenAPI 规范 的 Forbidden 响应描述中写明:"The error key identifies the resource type (article, comment, etc.)",即操作文章用 article,操作评论用 comment,消息固定为 forbidden:
{ "errors": { "article": ["forbidden"] } }
errors_authorization.hurl 完整演绎了这条规则的测试流程:
- 注册用户 A 与用户 B(第 1–25 行),分别捕获
token_a、token_b; - 用户 A 创建文章(第 27–39 行),捕获
slug; - 用户 B 尝试
DELETE /api/articles/{slug}→ 断言HTTP 403且errors.article[0] == "forbidden"(第 41–46 行); - 用户 B 尝试
PUT篡改正文为"hijacked"→ 同样 403(第 48–58 行); - 用户 A 在文章下创建评论后,用户 B 尝试删除该评论 → 403 且
errors.comment[0] == "forbidden"(第 72–77 行); - 验证失败的删除没有产生副作用——再次
GET确认评论仍然存在(第 79–84 行)。
这套断言隐含了一个重要的实现要求:403 必须是"拒绝且无副作用"的——被拒绝的写操作不得修改任何数据,测试第 79–84 行专门验证了这一点。
404 Not Found:资源键与"not found"文案
规范定义:"404 for Not found requests, when a resource can't be found to fulfill the request"。与 403 类似,错误键标识未被找到的资源类型,消息为 not found。Hurl 套件覆盖了三种资源类型:
文章(article)——errors_articles.hurl 验证了所有以未知 slug 访问的端点都返回 404:
GET /api/articles/{slug}(第 14–18 行)PUT、DELETE未知 slug(第 139–182 行)POST/DELETE .../favorite未知 slug(第 151–163 行)
评论(comment)——errors_comments.hurl 展示了两级 404:对未知文章的任何评论操作(POST/GET/DELETE)返回 errors.article[0] == "not found"(第 56–79 行);而"文章存在但评论 ID 不存在"则返回 errors.comment[0] == "not found"(第 81–86 行)。这要求实现方在报错时指明究竟是哪一层资源缺失。
用户资料(profile)——errors_profiles.hurl 验证 GET /api/profiles/{username}、POST/DELETE .../follow 对未知用户返回 404 且 errors.profile[0] == "not found"(第 1–5 行、第 32–43 行)。
422 校验失败:按字段定位错误的完整清单
422 是错误契约中覆盖面最广的一类。其核心原则是:错误键直接等于请求体中出错字段的名字,让前端能精确地把错误信息定位到对应输入框。以下是 Hurl 套件固化下来的全部 422 校验场景:
注册(POST /api/users)——errors_auth.hurl 第 1–38 行:
{ "user": { "username": "", "email": "x@test.com", "password": "password123" } }
HTTP 422 { "errors": { "username": ["can't be blank"] } }
username、email、password 三个字段分别断言。
登录(POST /api/users/login)——email 或 password 为空均返回 422 + 对应字段键(第 79–101 行)。
更新用户(PUT /api/user)——除"字段为空"外,还有一批针对必填字段传入 null 的拒绝规则(第 132–194 行):email、username 传空字符串或 null,password 传空字符串或 null,均应返回 422。
密码策略(NIST 800-63B)——errors_auth.hurl 第 172–224 行针对 PUT /user 的密码更新固化了如下策略(测试注释中引用了 NIST 800-63B 第 5.1.1.2 节):
| 输入 | 期望 |
|---|---|
少于 8 个字符(如 short7c) |
422 拒绝 |
8 个字符的简单词(如 bonjour1) |
200 接受 |
| 64 个字符 | 200 接受(必须能接受至少 64 字符,不得设更短上限) |
创建文章(POST /api/articles)——errors_articles.hurl 第 68–108 行分别对空 title、空 description、空 body 断言 422 + can't be blank。同时第 110–137 行固化了一个反向规则:标题重复是允许的(每篇文章生成唯一 slug),不产生任何错误。
创建评论(POST /api/articles/{slug}/comments)——空 body 返回 422(errors_comments.hurl 第 44–54 行)。
与空值归一化的边界:哪些"空"是错误,哪些是合法的 null
错误处理契约与数据归一化规则在边界处紧密咬合。以 PUT /user 为例(auth.hurl 与 errors_auth.hurl 对照):
- 可空字段
bio、image:传空字符串是合法的,且必须被归一化为null(auth.hurl 第 81–98 行断言"bio": ""更新后返回bio == null);传null也被接受(第 112–122 行); - 必填字段
username、email、password:传空字符串或 null 一律 422(errors_auth.hurl 第 132–194 行)。
实现方需要按"字段是否可空"区分这两条路径,这正是契约测试存在的原因。
运行错误处理测试套件:从脚本到断言
仓库提供了两套可直接运行的契约测试,均以"错误行为是否符合上表"为断言目标:
Hurl 套件(推荐)——run-hurl-tests.sh 的完整实现为:
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
HOST="${HOST:-http://localhost:8000}"
UID_VAL="${UID_VAL:-$(date +%s)$$}"
FILES=("$@")
if [ ${#FILES[@]} -eq 0 ]; then
FILES=("$DIR"/*.hurl)
fi
hurl --test --jobs 1 --variable "host=$HOST" --variable "uid=$UID_VAL" "${FILES[@]}"
关键参数说明:
HOST环境变量指定被测 API 地址,默认http://localhost:8000;UID_VAL为每轮测试注入唯一后缀(默认取时间戳+进程号),保证用户名字段、slug 等在全新数据库中不冲突;- 不带参数运行时执行全部
*.hurl文件;只验证错误契约时可单独传入错误相关文件,例如run-hurl-tests.sh errors_auth.hurl errors_authorization.hurl; --jobs 1保证请求顺序执行,避免并行干扰有依赖关系的断言。
各文件与错误主题的对应对照如下:
| 文件 | 验证内容 |
|---|---|
| errors_auth.hurl | 注册/登录/更新用户的 422、401、409 与密码策略 |
| errors_articles.hurl | 文章的 401/404/422 |
| errors_authorization.hurl | 跨用户越权操作的 403 |
| errors_comments.hurl | 评论的两级 404 与 422 |
| errors_profiles.hurl | 资料/关注的 401/404 |
仓库同时提供 Bruno 集合(specs/api/bruno/errors-articles/、errors-auth/、errors-authorization/、errors-comments/、errors-profiles/ 等目录),其中的 .bru 请求与上述 Hurl 断言一一对应(如 01-register-empty-username.bru),可供偏好用图形化工具调试的场景,并配合 run-api-tests-bruno.sh 批量执行。
实现要点小结
综合 error-handling.md、OpenAPI 规范 与 Hurl/Bruno 测试,实现或校验 RealWorld 后端时,错误处理必须满足:
- 所有错误共用
GenericErrorModel结构:{ "errors": { "<key>": ["<message>", ...] } }; - 422 的键等于校验失败的请求字段名,文案以
can't be blank系为准; - 401 分两种:token 缺失用
token键 +is missing;凭证错误用credentials键 +invalid; - 403 与 404 的键等于资源类型(
article、comment、profile),文案分别为forbidden与not found; - 唯一性冲突返回 409 +
has already been taken; - 被 403 拒绝的写操作必须无任何副作用;
- 可空字段(
bio/image)的空字符串应归一化为null而非报 422,必填字段的空值一律 422。
由于各技术栈实现(React、Angular、Node、Django 等)都以同一份契约对接前端,上述每一条都可以通过仓库内自带的 Hurl/Bruno 套件在本地直接复现验证——这也是 RealWorld 作为全栈示范项目的核心价值之一:错误行为不再依赖口头约定,而是由可执行测试精确锁定。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00