首页 / 文章

RFC 9457详解:HTTP API错误响应的标准化

RFC 9457《问题详情》为HTTP API提供了统一的错误格式标准。明确了各字段的含义,以及如何通过全局过滤器在NestJS中正确实现该格式。

阅读约 7 分钟1562 词

几乎所有的 API 最终都会返回类似以下的错误数据:

{
  "message": "Something went wrong",
  "error": "Bad Request",
  "statusCode": 400
}

问题在于,这种格式几乎总是与生成它的框架相关联。NestJS、Express、Spring、Rails 以及无数其他技术栈各自都会生成具有不同字段名称和结构的错误对象。

RFC 9457 的标题为HTTP API 的问题详情,它通过提出一种标准化的 HTTP API 错误数据格式来解决这一问题。

该规范于 2023 年 7 月发布,取代了之前的 RFC 7807。其核心理念很简单:不必让每个 API 都设计自己的错误格式,大家都可以使用一种通用的、机器可读的结构。

它解决的问题

HTTP 状态码只能告诉你发生了什么大致类别:

400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
409 Conflict
422 Unprocessable Content
500 Internal Server Error

但仅知道返回了400状态码并无法说明故障的真正原因。

以这样的响应为例:

HTTP/1.1 409 Conflict

那条状态行可能表示用户名已被占用、资源已存在、版本不匹配,或是其他各种冲突情况。

RFC 9457通过在响应体中加入结构化且描述性的详细信息来解决这一问题,这样客户端无需反编译特定框架的错误格式就能了解出了什么问题。

问题详情对象

符合此规范的响应是一个以该媒体类型传输的JSON文档:

Content-Type: application/problem+json

规范规定了五个标准字段:

{
  "type": "https://example.com/problems/username-taken",
  "title": "Username already exists",
  "status": 409,
  "detail": "The username 'ali' is already registered.",
  "instance": "/users"
}

这五个字段都不是必填的,但每个都有其特定的作用。

type

此字段用于标识所报告的问题类别。

{
  "type": "https://example.com/problems/username-taken"
}

可将其视为机器可读取的错误标签。理想情况下,type 中的 URI 应指向能够解释该问题以及客户端应如何处理的真实文档。

如果未设置 type,则默认视为已设置为:

about:blank

客户端应将该 type URI 作为标准标识符,而非试图从 detail 文本中解析含义。

title

这是一个简短且易于理解的标签,用于描述问题类型。

{
  "title": "Username already exists"
}

由于 title 描述的是一般性问题类型而非具体实例,因此要求在相同错误的不同出现情况下保持一致。

status

这与与该错误相关的HTTP状态码相对应。

{
  "status": 409
}

需要注意的一个细节是:此字段仅具有建议性意义。

真正的权威状态码是由HTTP响应本身所携带的:

HTTP/1.1 409 Conflict

当服务器构建响应体时,status值应与实际的响应状态码保持一致。

detail

此字段提供了针对该次错误发生的具体、便于人类理解的描述。

{
  "detail": "The username 'ali' is already registered."
}

不要依赖detail来编写任何机器逻辑。

尤其要避免编写如下类型的客户端代码:

if (error.detail.includes("already registered")) {
  // ...
}

对于任何需要通过程序进行检测的内容,应依赖type或自定义的扩展成员而非此字段。

instance

该字段指向所报告问题的具体出现位置。

{
  "instance": "/users"
}

在需要追踪故障、为用户提供支持、关联日志记录或调查特定请求时,这一字段非常有用。

它的作用与type不同:

type      -> what kind of problem is this?
instance  -> which occurrence of that problem is this?

扩展功能

该规范有意为核心字段之外预留了添加其他字段的空间。

例如,验证失败的响应可能如下所示:

{
  "type": "https://example.com/problems/validation-error",
  "title": "Validation failed",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "errors": [
    {
      "field": "email",
      "message": "Invalid email address"
    },
    {
      "field": "password",
      "message": "Password is too short"
    }
  ]
}

此处,errors并非RFC 9457规范本身的组成部分——它是您的API自行定义的扩展成员。

这种灵活性正是该规范的优点之一:您既能获得统一且可预测的框架,又能自由添加应用程序所需的任何领域特定细节。

HTTP标头

采用此格式的响应应设置:

Content-Type: application/problem+json

客户端可通过以下方式表明对这种格式的支持:

Accept: application/problem+json

像 title 和 detail 这样的可读字段也可以通过标准的 HTTP 请求头进行约定:

Accept-Language: en

服务器会返回类似如下的内容:

Content-Language: en

当客户端需要在重试前等待时,可加入标准的 Retry-After 请求头:

Retry-After: 30

例如:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 30

内容如下:

{
  "type": "https://example.com/problems/rate-limit",
  "title": "Too many requests",
  "status": 429,
  "detail": "Rate limit exceeded."
}

RFC 9457 并未发明新的重试机制——它只是在适合特定问题类型的场景下重新使用了现有的 HTTP Retry-After 请求头。

RFC 9457 未定义的内容

该规范对以下内容未作规定:

  • 应用层错误代码
  • 验证错误的结构方式
  • 日志记录规范
  • 异常类的组织结构
  • 身份验证或授权逻辑
  • API处理异常的内部机制
  • 其作用仅限于为描述HTTP层问题提供标准格式。

    这意味着如下形式的响应完全符合规范:

    {
      "type": "https://api.example.com/problems/validation-error",
      "title": "Validation failed",
      "status": 422,
      "detail": "Request validation failed.",
      "code": "VALIDATION_ERROR",
      "errors": [
        {
          "field": "email",
          "message": "Invalid email address"
        }
      ]
    }
    

    在此示例中,code和errors是该API特有的扩展内容,并非基础规范的一部分。

    给开发者的实用规则

    切勿在生产环境响应中泄露堆栈跟踪信息或原始异常消息。

    避免以下情况:

    {
      "detail": "QueryFailedError: duplicate key value violates unique constraint..."
    }
    

    建议采用如下方式:

    {
      "type": "https://api.example.com/problems/conflict",
      "title": "Resource conflict",
      "status": 409,
      "detail": "The username is already in use."
    }
    

    同时不要让客户端依赖对detail字符串的解析。

    切勿这样做:

    if (error.detail === "User already exists") {
    }
    

    应改为检查 type 字段:

    if (error.type === "https://api.example.com/problems/user-exists") {
    }
    

    将 type 视为稳定的标识符。

    相同的 type 应始终代表同一类问题,尽管 detail 在不同情况下会自然有所差异。

    不要为每个控制器或端点都创建新的错误格式。在整个 API 中统一使用一种 Problem Details 结构,仅在确实需要时才添加自定义字段。

    最后,不要将 Problem Details 响应与正常的成功响应混为一谈。这种格式专门用于描述故障,应当与 4xx 和 5xx 状态码一起使用。

    在 NestJS 中实现 RFC 9457

    在 NestJS 中处理此问题的简洁方法是将相关逻辑集中在全局异常过滤器中。

    import {
      ArgumentsHost,
      Catch,
      ExceptionFilter,
      HttpException,
      HttpStatus,
    } from '@nestjs/common';
    import type { Request, Response } from 'express';
    
    
    interface ProblemDetails {
      type?: string;
      title?: string;
      status?: number;
      detail?: string;
      instance?: string;
      [key: string]: unknown;
    }
    
    @Catch()
    export class ProblemDetailsExceptionFilter implements ExceptionFilter {
      catch(exception: unknown, host: ArgumentsHost): void {
    
        const ctx = host.switchToHttp();
    
        const request = ctx.getRequest<Request>();
        const response = ctx.getResponse<Response>();
    
        let status = HttpStatus.INTERNAL_SERVER_ERROR;
        let detail = 'An unexpected error occurred.';
        let extensions: Record<string, unknown> = {};
    
        if (exception instanceof HttpException) {
          status = exception.getStatus();
          const errorResponse = exception.getResponse();
    
          if (typeof errorResponse === 'string') {
            detail = errorResponse;
          } else if (typeof errorResponse === 'object' && errorResponse !== null) {
            const body = errorResponse as Record<string, unknown>;
            if (typeof body.message === 'string') {
              detail = body.message;
            }
            const {
              statusCode: _statusCode,
              error: _error,
              message: _message,
              ...rest
            } = body;
            extensions = rest;
          }
        }
    
        const problem: ProblemDetails = {
          type: 'about:blank',
          title: HttpStatus[status] ?? 'HTTP Error',
          status,
          detail,
          instance: request.originalUrl,
          ...extensions,
        };
    
        response
          .status(status)
          .type('application/problem+json')
          .json(problem);
      }
    }
    

    然后在整个应用程序中实现该功能:

    app.useGlobalFilters(
      new ProblemDetailsExceptionFilter(),
    );
    

    而不是返回 NestJS 的默认错误格式,例如:

    {
      "statusCode": 404,
      "message": "User not found",
      "error": "Not Found"
    }
    

    您的接口将始终输出:

    HTTP/1.1 404 Not Found
    Content-Type: application/problem+json
    
    {
      "type": "about:blank",
      "title": "Not Found",
      "status": 404,
      "detail": "User not found",
      "instance": "/users/42"
    }
    

    从这时起,您可以自由地为特定的错误情况定义自己统一的 type URI:

    https://api.example.com/problems/user-not-found
    https://api.example.com/problems/validation-error
    https://api.example.com/problems/username-taken
    https://api.example.com/problems/rate-limit
    

    这种设置能让您的 API 具有可预测、统一的错误规范,同时 NestJS 的内置异常以及您自定义的应用错误仍可在其基础上正常工作。