---
id: 982178b2b9c8c091685bdc7fbf942c43
author: snowmerak
title: 以更省心的方式处理 HTTP 错误 + RFC7807
description: Go API 错误处理很繁琐吗？使用 RFC7807 可以更简便、更清晰地处理错误。
language: zh
date: 2025-05-19T20:56:37.945722346Z
path: /blog/posts/handling-http-errors-more-conveniently-+-rfc7807-z862df968
category: api
---

## 概述

在 Go 语言中创建 HTTP API 时，最麻烦的是错误处理。典型的代码如下所示。

```go
func(w http.ResponseWriter, r *http.Request) {
    err := doSomething()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        log.Printf("error occurred: %v", err)
        return
    }
    // ...
}
```

如果 API 数量不多，这样编写也不会有什么明显的不便。但是，随着 API 数量增加、内部逻辑变得复杂，有三件事会变得令人困扰。

1. 返回适当的错误码
2. 编写大量的结果日志
3. 发送清晰的错误消息

## 正文

### 返回适当的错误码

当然，第 1 点"返回适当的错误码"确实是我个人的不满之处。经验丰富的开发者会找到合适的代码并每次都正确填写，但包括我在内，尚不熟练的开发者在逻辑变得复杂、调用次数增多时，可能会难以有规律地使用合适的错误码。对此可以有多种方法，最具代表性的是事先设计好 API 的逻辑流程，然后编写代码以返回适当的错误。~~那就这样做吧~~

但是，对于借助 IDE（或 Language Server）的人类开发者而言，这似乎并不是最优的方法。此外，由于 REST API 本身就充分利用了错误码所承载的含义，因此可以提出另一种方案。创建一个实现 `error` 接口的新类型 `HttpError`，使其保存 `StatusCode` 和 `Message`。然后提供如下辅助函数。

```go
err := httperror.BadRequest("wrong format")
```

`BadRequest` 辅助函数会返回一个 `HttpError`，其中 `StatusCode` 被设置为 400，`Message` 被设置为传入的参数值。除此之外，当然还可以通过自动补全功能查找和添加 `NotImplement`、`ServiceUnavailable`、`Unauthorized`、`PaymentRequired` 等辅助函数。这比每次查阅事先准备好的设计文档更快，也比每次手动输入数字错误码更可靠。~~`http.StatusCode` 常量里不是都有吗？嘘~~

### 编写大量的结果日志

发生错误时自然会记录日志。在记录 API 被调用、请求成功还是失败的日志时，如果从一开始就在所有预期的结束点记录日志，所需编写的代码量会很多。通过包装处理器本身，就可以在中心位置进行统一管理。

下面是包装 `chi` 路由器的示例。

```go
package chiwrap

import (
	"errors"
	"net/http"

	"github.com/go-chi/chi/v5"

	"github.com/gosuda/httpwrap/httperror"
)

type Router struct {
	router      chi.Router
	errCallback func(err error)
}

func NewRouter(errCallback func(err error)) *Router {
	if errCallback == nil {
		errCallback = func(err error) {}
	}
	return &Router{
		router:      chi.NewRouter(),
		errCallback: errCallback,
	}
}

type HandlerFunc func(writer http.ResponseWriter, request *http.Request) error

func (r *Router) Get(pattern string, handler HandlerFunc) {
	r.router.Get(pattern, func(writer http.ResponseWriter, request *http.Request) {
		if err := handler(writer, request); err != nil {
			he := &httperror.HttpError{}
			switch errors.As(err, &he) {
			case true:
				http.Error(writer, he.Message, he.Code)
			case false:
				http.Error(writer, err.Error(), http.StatusInternalServerError)
			}
			r.errCallback(err)
		}
	})
}
```

路由器结构体内部持有 `chi.Router`，因此可以直接使用 `chi.Router` 的全部功能。观察 `Get` 方法，它会检查返回的错误是否为刚才上面提出的辅助函数所返回的 `HttpError` 结构体，并进行适当的返回；如果是普通的 `error`，则统一传递给错误回调函数。该回调通过构造函数传入。

下面是利用该包编写的代码。

```go
package main

import (
	"bytes"
	"context"
	"errors"
	"io"
	"log"
	"net/http"

	"github.com/gosuda/httpwrap/httperror"
	"github.com/gosuda/httpwrap/wrapper/chiwrap"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

	r := chiwrap.NewRouter(func(err error) {
		log.Printf("Router log test: Error occured: %v", err)
	})
	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
		name := request.URL.Query().Get("name")
		if name == "" {
			return httperror.BadRequest("name is required")
		}

		writer.Write([]byte("Hello " + name))
		return nil
	})

	svr := http.Server{
		Addr:    ":8080",
		Handler: r,
	}
	go func() {
		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Failed to start server: %v", err)
		}
	}()

    <-ctx.Done()
    svr.Shutdown(context.Background())
}
```

怎么样？仅仅将 `HttpError` 做成辅助函数并返回，上层作用域就能以适当的错误码和消息进行响应，同时还可以通过注册回调，为每个服务实现记录适当的日志。如有需要，还可以进一步扩展，利用 `RequestID` 等实现详细的日志记录。

### 发送清晰的错误消息

为此可以参考的文档是 RFC7807。RFC7807 主要定义并使用以下元素。

- `type`：标识错误类型的 URI，通常是对该错误进行说明的文档。
- `title`：对错误内容的简短一行描述。
- `status`：与 HTTP Status Code 相同。
- `detail`：针对该错误的、便于人类阅读的详细说明。
- `instance`：发生错误的 URI。例如，若在 `GET /user/info` 中发生错误，则其值为 `/user/info`。
- `extensions`：以 JSON Object 形式构成的、用于说明错误的附加元素。
  - 例如，在 `BadRequest` 的情况下，可以包含用户的输入内容。
  - 或者，在 `TooManyRequest` 的情况下，也可以包含最近一次请求的时间点。

为了便于使用，在与 `HttpError` 位于同一位置的 `httperror` 包中新建一个文件，创建 `RFC7807Error` 结构体，并使其能够以方法链模式创建。

```go
func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
	return &RFC7807Error{
		Type:   "about:blank", // Default type as per RFC7807
		Title:  title,
		Status: status,
		Detail: detail,
	}
}

func BadRequestProblem(detail string, title ...string) *RFC7807Error {
	t := "Bad Request"
	if len(title) > 0 && title[0] != "" {
		t = title[0]
	}
	return NewRFC7807Error(http.StatusBadRequest, t, detail)
}

func (p *RFC7807Error) WithType(typeURI string) *RFC7807Error { ... }
func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... }
func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... }
```

`Type` 中的 `"about:blank"` 是默认值，表示不存在的页面。下面是创建错误请求相关错误的示例。

```go
problem := httperror.BadRequestProblem("invalid user id format", "Bad User Input")

problem = problem.WithType("https://example.com/errors/validation")
         .WithInstance("/api/users/abc")
         .WithExtension("invalid_field", "user_id")
         .WithExtension("expected_format", "numeric")
```

通过简单的方法链，即可生成面向用户的结构化错误消息。此外，为了利用前面编写的中心化路由器，可以支持以下方法。

```go
func (p *RFC7807Error) ToHttpError() *HttpError {
	jsonBytes, err := json.Marshal(p)
	if err != nil {
		// If marshaling fails, fall back to just using the detail
		return New(p.Status, p.Detail)
	}
	return New(p.Status, string(jsonBytes))
}
```

直接使用它并据此修改上面的示例，代码如下。

```go
package main

import (
	"bytes"
	"context"
	"errors"
	"io"
	"log"
	"net/http"

	"github.com/gosuda/httpwrap/httperror"
	"github.com/gosuda/httpwrap/wrapper/chiwrap"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

	r := chiwrap.NewRouter(func(err error) {
		log.Printf("Router log test: Error occured: %v", err)
	})
	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
		name := request.URL.Query().Get("name")
		if name == "" {
			return httperror.BadRequestProblem("name is required", "Bad User Input").
                WithType("https://example.com/errors/validation").
                WithInstance("/api/echo").
                WithExtension("invalid_field", "name").
                WithExtension("expected_format", "string").
                WithExtension("actual_value", name).
                ToHttpError()
		}

		writer.Write([]byte("Hello " + name))
		return nil
	})

	svr := http.Server{
		Addr:    ":8080",
		Handler: r,
	}
	go func() {
		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Failed to start server: %v", err)
		}
	}()

    <-ctx.Done()
    svr.Shutdown(context.Background())
}
```

## 结论

通过使用这种中心化的路由器来处理错误，可以减轻每次都要确认错误码、编写合适错误消息的负担。此外，利用 RFC7807 提供结构化的错误消息，有助于客户端理解并处理错误。通过这些方法，可以使用 Go 语言编写的 HTTP API 的错误处理更加简便、一致。

本文的代码可以在 [gosuda/httpwrap](https://github.com/gosuda/httpwrap) 仓库中查看。
