用 RFC 9457 problem+json 统一 Express REST API 错误响应
【摘要】 REST API 错误响应格式不统一会让客户端难以通用处理。本文用 RFC 9457 的 application/problem+json,在 Express 中通过中间件统一输出 type、title、status、detail、instance,并用扩展成员 errors 承载字段级校验失败,附客户端按 type 分支处理的示例代码与边界说明。
问题场景
同一个服务内,各模块返回的错误结构常常不一致:有的返回 {"msg": "..."},有的返回 {"code": 1001, "message": "..."},还有的直接抛出 HTML 错误页。客户端因此不得不为每个接口写一套解析逻辑;即使 HTTP 状态码是 422,也无法定位到具体出错的字段。
RFC 9457(Problem Details for HTTP APIs)定义了一个通用的错误对象,媒体类型为 application/problem+json,预定义成员包括 type、title、status、detail、instance,并允许扩展自定义成员。下面用 Express 实现一层统一的错误出口。
设计约定
type:稳定标识错误类别的 URI,客户端据此分支处理;默认值为about:blank。title:面向人的简短说明,同类错误应保持固定,便于展示和检索。status:必须与 HTTP 状态码保持一致。detail:本次请求的具体说明,不写堆栈、SQL、内部主机名。instance:本次请求的标识(这里用请求路径占位,便于和日志关联)。errors:扩展成员,承载字段级校验失败,每项含pointer、code、detail。
最小可运行示例
新建目录后安装依赖,编写 server.js:
// server.js
const express = require('express');
const app = express();
app.use(express.json());
const BASE = 'https://api.example.com/problems'; // 占位域名
class ApiError extends Error {
constructor(opts) {
super(opts.detail || opts.title || 'error');
this.type = opts.type || 'about:blank';
this.title = opts.title || 'Internal Server Error';
this.status = opts.status || 500;
this.detail = opts.detail;
this.instance = opts.instance;
this.errors = opts.errors;
}
}
function sendProblem(req, res, err) {
const status = Number.isInteger(err.status) ? err.status : 500;
const body = {
type: err.type || 'about:blank',
title: err.title || 'Internal Server Error',
status,
instance: err.instance || req.originalUrl,
};
if (err.detail) body.detail = err.detail;
if (Array.isArray(err.errors) && err.errors.length > 0) {
body.errors = err.errors;
}
res.set('Content-Type', 'application/problem+json; charset=utf-8');
res.status(status).json(body);
}
app.post('/users', (req, res, next) => {
const { name, email } = req.body || {};
const errors = [];
if (typeof name !== 'string' || name.trim() === '') {
errors.push({ pointer: '/name', code: 'required', detail: 'name 不能为空' });
}
if (typeof email !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(email)) {
errors.push({ pointer: '/email', code: 'invalid_format', detail: 'email 格式不正确' });
}
if (errors.length > 0) {
return next(new ApiError({
type: BASE + '/validation-error',
title: '请求参数校验失败',
status: 422,
detail: '共 ' + errors.length + ' 个字段未通过校验',
errors: errors,
}));
}
res.status(201).json({ id: 'u_0001', name: name, email: email });
});
// 未匹配路由统一转成 problem 对象
app.use((req, res, next) => {
next(new ApiError({
type: BASE + '/not-found',
title: '资源不存在',
status: 404,
detail: '找不到 ' + req.method + ' ' + req.path,
}));
});
// 统一错误出口,必须保留 4 个参数
app.use((err, req, res, next) => {
if (err && err.type === 'entity.parse.failed') {
err = new ApiError({
type: BASE + '/malformed-json',
title: '请求体不是合法 JSON',
status: 400,
detail: '请检查 Content-Type 与 JSON 语法',
});
}
sendProblem(req, res, err || new ApiError({}));
});
app.listen(3000, () => console.log('listen on http://localhost:3000'));
运行方法:
npm init -y
npm i express
node server.js
触发一次校验失败:
curl -i -X POST http://localhost:3000/users -H 'Content-Type: application/json' -d '{"email":"not-an-email"}'
返回的响应体形如(示例值):
{
"type": "https://api.example.com/problems/validation-error",
"title": "请求参数校验失败",
"status": 422,
"instance": "/users",
"detail": "共 2 个字段未通过校验",
"errors": [
{ "pointer": "/name", "code": "required", "detail": "name 不能为空" },
{ "pointer": "/email", "code": "invalid_format", "detail": "email 格式不正确" }
]
}
客户端按 type 分支处理
客户端应先判断状态码与媒体类型,再用 type 做分支,未知 type 回退到按 status 处理:
async function createUser(payload) {
const res = await fetch('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) return res.json();
const ct = res.headers.get('content-type') || '';
if (!ct.includes('application/problem+json')) {
throw new Error('HTTP ' + res.status); // 非标准错误体,兜底
}
const problem = await res.json();
if (problem.type === 'https://api.example.com/problems/validation-error') {
const fieldErrors = new Map(
(problem.errors || []).map((e) => [e.pointer, e.detail])
);
// 把 fieldErrors 映射到表单字段提示
return { fieldErrors, message: problem.title };
}
if (problem.type === 'https://api.example.com/problems/not-found') {
return { message: '目标资源已被移除' };
}
throw new Error(problem.title || 'HTTP ' + res.status);
}
边界与限制
- 该媒体类型只用于错误响应,成功响应不要复用。
- 设置头部时用
res.set显式指定类型,避免后续json()覆盖Content-Type。 express.json()解析失败抛出的是SyntaxError,需单独映射成 400,否则会被当成 500 处理。status与真实 HTTP 状态码不一致会让代理、SDK 和监控误判,务必保持同步。type作为 URI 只承担标识作用,不要求可解引用;但建议指向错误码文档页,方便排查。errors是扩展成员,通用客户端应忽略未知成员;需要为每个code写明含义,否则前端无法稳定映射文案。detail面向调用方,不要写入堆栈、SQL、内部地址等敏感信息,这些只应进日志。- 多语言场景下按
Accept-Language生成title/detail,不要把语言编码进type。 - 若前置网关或负载均衡自带错误页,超时、限流等响应可能绕过本格式,需要一并配置。
按上述约定,服务端只需维护少量 type 常量与一个出口函数,客户端也只需一处分支逻辑,错误处理就从"各家各写"变成了可复用契约。
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)