用 RFC 9457 problem+json 统一 Express REST API 错误响应

举报
yd_240403232 发表于 2026/10/31 22:32:39 2026/10/31
【摘要】 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)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。