HTTP API 错误模型

2022-12-20, 星期二, 16:39

Dev

RFC 7230 定义的 HTTP 状态码在描述问题时存在语义模糊的问题。电子支付用户在产生消费行为时可能会收到 403 Forbidden,一些通用的,基于 HTTP 的中间件,例如库、缓存、代理可能不会有什么意见,然而客户端却可能既不能理解问题到底是来自「余额不足」还是用户「没有特定商品的购买资格」,也无法(用足够的信息引导用户)解决该问题。因此在基于 JSON 的 HTTP API 设计中,我们通常会在响应体中塞一个错误模型。

Spring MVC 在处理异常时(比如参数校验不通过,抛出 MethodArgumentNotValidException)默认返回如下结构:

HTTP/1.1 400
Content-Type: application/json

{
    "timestamp": "2022-12-16T06:14:44.549+00:00",
    "status": 400,
    "error": "Bad Request",
    "path": "/register"
}

有些项目将 HTTP API 视为一种传输数据的基础设施,这种思路下业务代码会主动捕获异常,包装成 APIResult 并使用 200 Success 返回。

public class Result<T> implements Serializable {
    // 自定义的业务状态代码
    private ResultCode code;
    private List<T> data;
    // 有的时候是错误描述
    private String message;
}

我第一次看到这张图是在 How do you know what’s gone wrong when your API request fails?

其他组织是怎么做的

GitHub API

GitHub 提供了一系列 REST API 供开发者调用。

Search code API 为例,故意遗漏 URL 查询参数 q。响应简要描述了错误详情(使用了数组,必要时可以描述多个错误)以及为了协助调用者排除问题指出的文档位置。

HTTP/2 422
content-type: application/json; charset=utf-8

{
    "message": "Validation Failed",
    "errors": [
        {
            "resource": "Search",
            "field": "q",
            "code": "missing"
        }
    ],
    "documentation_url": "https://docs.github.com/v3/search"
}

Directus

Directus 是一款主流的 Headless CMS,其与内容展示端的交互主要靠基于 JSON 的 HTTP API 和 GraphQL。

在请求某个 Collection 的内容时故意构造错误的查询条件块,响应体通过 errors 数组描述了错误。

HTTP/1.1 400
Content-Type: application/json; charset=utf-8

{
    "errors": [
        {
            "message": "****** field type does not contain the ****** filter operator",
            "extensions": {
                "code": "INVALID_QUERY"
            }
        }
    ]
}

RFC 7807 Problem Details

IETF 提议的 RFC 7807 标准定义了一种可读性较好的 Problem Detail 类型,即为通用组件提供了抽象层度较高的 HTTP 状态码,又为具体错误处理机制提供了细粒度的描述。

Problem Detail 一般由以下部分组成:

  • type: URI:指向对应解决方案的文档的 URI,是确定问题的类型的主要依据
  • title: string:简短易读的错误描述,起到对 type 补充说明的作用
  • status: number:错误状态码,在服务调用发生嵌套时尤其有用,方便起见可以套用 HTTP 状态码
  • detail: string:对错误产生的原因做简要阐述,但是不要把 debug 信息放进去,也不要试图把结构化信息 stringify 后存储在这里
  • instance: URI:发生错误的 endpoint(相对路径)

开发者可以自行扩展属性以携带其他信息,例如 errors[]trace/span 等。

标准还建议 API 返回特定的 Content-Type 例如 application/problem+jsonapplication/problem+xml

o.s.h.ProblemDetail

Spring 通过 ResponseEntityExceptionHandler 可以捕获几种常见的异常(例如 MethodArgumentNotValidException 什么的)。在 Spring Boot 3 中,继承 ResponseEntityExceptionHandler 并使用 RestControllerAdvice 注解修饰,Spring Boot 就会为这几种异常返回 org.springframework.http.ProblemDetail 类型。

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
}
HTTP/1.1 400 
Content-Type: application/problem+json

{
    "type": "about:blank",
    "title": "Bad Request",
    "status": 400,
    "detail": "Invalid request content.",
    "instance": "/register"
}

应用 RFC 8707 的挑战

RFC 8707 的一些设计在实际项目实施中有一些难度:

  • type 字段一般是指向解决方案的 HTML 文档的 URI,这些具体的文档在大部分项目中应该是缺失的(或对其表达形式缺乏较好的阐明)
  • 草案要求客户端根据 type 字段区分问题类型和识别扩展字段,添加新的扩展字段就意味着增加 type 的类型
  • 多个原因导致用户请求失败,最好一起反馈给请求方

这些问题还有待实践摸索,例如对第一个问题,有的项目就设置了指向对应 HTTP 状态码的 RFC 文档。对第三个问题,本文建议增加 explanations 数组供参考,至于 explanation 的结构则暂无较好的想法,还是暂时依靠人工介入好了。

HTTP/1.1 400 
Content-Type: application/problem+json

{
    "type": "about:blank",
    "title": "Validation Error",
    "status": 400,
    "detail": "Method Argument Not Valid",
    "instance": "/user/register",
    "explanations": [
        {
            "field": "email",
            "message": "must be a well-formed email address"
        },
        {
            "field": "phone",
            "message": "phone number must be 11 digits"
        },
        {
            "field": "address.zipcode",
            "message": "zip code must be 6 digits"
        }
    ]
}

在 Spring Boot 3 中实现:

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidationExceptions(MethodArgumentNotValidException ex) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Method Argument Not Valid");
        problemDetail.setTitle("Validation Error");
        var explanations = ex.getBindingResult().getAllErrors().stream()
                .map((error) -> {
                    var explanation = new HashMap<String, String>();
                    explanation.put("field", ((FieldError) error).getField());
                    explanation.put("message", error.getDefaultMessage());
                    return explanation;
                })
                .toList();
        problemDetail.setProperty("explanations", explanations);
        return problemDetail;
    }
}

需要注意的是,为了方便配合日志查询和解决问题,常常需要为错误返回 trace 信息,这方面的工作可以交给一些日志链路追踪框架。