Более удобная обработка HTTP-ошибок + RFC7807

Обзор
При создании HTTP API на языке Go самая утомительная часть это обработка ошибок. Типичный пример выглядит так.
1func(w http.ResponseWriter, r *http.Request) {
2 err := doSomething()
3 if err != nil {
4 http.Error(w, err.Error(), http.StatusInternalServerError)
5 log.Printf("error occurred: %v", err)
6 return
7 }
8 // ...
9}
Если API немного, писать код подобным образом особых неудобств не создаёт. Однако по мере роста количества API и усложнения внутренней логики начинают раздражать три вещи.
- Возврат подходящего кода ошибки
- Большой объём кода для записи логов
- Передача понятного сообщения об ошибке
Основная часть
Возврат подходящего кода ошибки
Пункт 1, возврат подходящего кода ошибки, действительно является моей личной претензией. Опытный разработчик всегда подберёт нужный код и аккуратно его укажет, но и мне, и другим начинающим разработчикам с ростом сложности логики и количества обработчиков становится трудно последовательно использовать подходящие коды ошибок. Существует несколько способов решить эту проблему, самый распространённый из которых: заранее спроектировать поток логики API и затем написать код, который возвращает соответствующую ошибку. Так и делайте
Однако этот подход не выглядит оптимальным для человека-разработчика, которому помогают IDE (или Language Server). Кроме того, поскольку REST API сам по себе максимально опирается на смысл, заключённый в кодах ошибок, можно предложить другой способ. Создадим новую реализацию интерфейса ошибки (error) с именем HttpError, которая хранит StatusCode и Message. А затем предоставим такие вспомогательные функции:
1err := httperror.BadRequest("wrong format")
Вспомогательная функция BadRequest вернёт HttpError, в котором StatusCode равен 400, а Message установлен из переданного аргумента. Помимо неё, разумеется, можно будет находить и добавлять вспомогательные функции NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired и другие через автодополнение. Это быстрее, чем каждый раз сверяться с заранее подготовленной таблицей, и надёжнее, чем каждый раз вводить коды ошибок числами. Вы скажете, что всё есть в константах http.StatusCode? Тсс
Большой объём кода для записи логов
При возникновении ошибки, конечно, нужно вести журнал. При записи логов о вызове API и о том, завершился ли запрос успешно или с ошибкой, если логировать каждую ожидаемую точку завершения с самого начала, объём кода сильно возрастает. Если обернуть сам обработчик, логирование можно будет вести централизованно.
Ниже приведён пример оборачивания роутера chi.
1package chiwrap
2
3import (
4 "errors"
5 "net/http"
6
7 "github.com/go-chi/chi/v5"
8
9 "github.com/gosuda/httpwrap/httperror"
10)
11
12type Router struct {
13 router chi.Router
14 errCallback func(err error)
15}
16
17func NewRouter(errCallback func(err error)) *Router {
18 if errCallback == nil {
19 errCallback = func(err error) {}
20 }
21 return &Router{
22 router: chi.NewRouter(),
23 errCallback: errCallback,
24 }
25}
26
27type HandlerFunc func(writer http.ResponseWriter, request *http.Request) error
28
29func (r *Router) Get(pattern string, handler HandlerFunc) {
30 r.router.Get(pattern, func(writer http.ResponseWriter, request *http.Request) {
31 if err := handler(writer, request); err != nil {
32 he := &httperror.HttpError{}
33 switch errors.As(err, &he) {
34 case true:
35 http.Error(writer, he.Message, he.Code)
36 case false:
37 http.Error(writer, err.Error(), http.StatusInternalServerError)
38 }
39 r.errCallback(err)
40 }
41 })
42}
Структура роутера содержит внутри chi.Router, поэтому функциональность chi.Router используется без изменений. В методе Get проверяется, возвращена ли структура HttpError, которую создают вспомогательные функции, предложенные выше, и в случае успеха возвращается соответствующий ответ. Если же это обычная error, она единообразно передаётся в функцию обратного вызова для ошибок. Эта функция принимается через конструктор.
Ниже приведён код, написанный с использованием этого пакета.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 log.Printf("Router log test: Error occured: %v", err)
21 })
22 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
23 name := request.URL.Query().Get("name")
24 if name == "" {
25 return httperror.BadRequest("name is required")
26 }
27
28 writer.Write([]byte("Hello " + name))
29 return nil
30 })
31
32 svr := http.Server{
33 Addr: ":8080",
34 Handler: r,
35 }
36 go func() {
37 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
38 log.Fatalf("Failed to start server: %v", err)
39 }
40 }()
41
42 <-ctx.Done()
43 svr.Shutdown(context.Background())
44}
Как вам такой вариант? Если просто создавать HttpError через вспомогательные функции и возвращать их, то на верхнем уровне можно отвечать клиенту подходящим кодом и сообщением, а для каждого сервиса регистрировать обратный вызов, который ведёт соответствующие логи. При необходимости можно расширить подход, например, используя RequestID, и вести более подробное логирование.
Передача понятного сообщения об ошибке
Для этого существует документ RFC7807. RFC7807 в основном определяет и использует следующие элементы.
type: URI, идентифицирующий тип ошибки. Обычно это документ, описывающий ошибку.title: краткое однострочное описание ошибки.status: совпадает с HTTP Status Code.detail: подробное описание ошибки, понятное человеку.instance: URI, в котором возникла ошибка. Например, если ошибка возникла приGET /user/info, значением будет/user/info.extensions: дополнительные элементы для пояснения ошибки, представленные в виде JSON-объекта.- Например, для
BadRequestможет включаться пользовательский ввод. - Или для
TooManyRequestможет включаться время самого последнего запроса.
- Например, для
Чтобы упростить использование, создадим новый файл в пакете httperror, расположенном рядом с HttpError, определим структуру RFC7807Error и сделаем так, чтобы её можно было создавать через цепочку вызовов методов.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Значение по умолчанию согласно RFC7807
4 Title: title,
5 Status: status,
6 Detail: detail,
7 }
8}
9
10func BadRequestProblem(detail string, title ...string) *RFC7807Error {
11 t := "Bad Request"
12 if len(title) > 0 && title[0] != "" {
13 t = title[0]
14 }
15 return NewRFC7807Error(http.StatusBadRequest, t, detail)
16}
17
18func (p *RFC7807Error) WithType(typeURI string) *RFC7807Error { ... }
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... }
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... }
Значение "about:blank" для Type является значением по умолчанию. Оно означает, что страница отсутствует. Ниже приведён пример создания ошибки для неверного запроса.
1problem := httperror.BadRequestProblem("invalid user id format", "Bad User Input")
2
3problem = problem.WithType("https://example.com/errors/validation")
4 .WithInstance("/api/users/abc")
5 .WithExtension("invalid_field", "user_id")
6 .WithExtension("expected_format", "numeric")
С помощью простой цепочки вызовов методов можно создать структурированное сообщение об ошибке для пользователя. Кроме того, для использования ранее созданного централизованного роутера можно поддержать следующий метод.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Если маршалинг завершился ошибкой, используем только detail
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Если использовать его напрямую и изменить предыдущий пример, получится следующее.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 log.Printf("Router log test: Error occured: %v", err)
21 })
22 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
23 name := request.URL.Query().Get("name")
24 if name == "" {
25 return httperror.BadRequestProblem("name is required", "Bad User Input").
26 WithType("https://example.com/errors/validation").
27 WithInstance("/api/echo").
28 WithExtension("invalid_field", "name").
29 WithExtension("expected_format", "string").
30 WithExtension("actual_value", name).
31 ToHttpError()
32 }
33
34 writer.Write([]byte("Hello " + name))
35 return nil
36 })
37
38 svr := http.Server{
39 Addr: ":8080",
40 Handler: r,
41 }
42 go func() {
43 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
44 log.Fatalf("Failed to start server: %v", err)
45 }
46 }()
47
48 <-ctx.Done()
49 svr.Shutdown(context.Background())
50}
Заключение
Если обрабатывать ошибки через централизованный роутер, можно снизить нагрузку, связанную с необходимостью каждый раз проверять код ошибки и формулировать подходящее сообщение. Кроме того, использование RFC7807 для передачи структурированных сообщений об ошибках помогает клиенту понимать ошибку и обрабатывать её. Так можно сделать обработку ошибок в HTTP API, написанных на языке Go, более простой и единообразной.
Код из этой статьи можно посмотреть в репозитории gosuda/httpwrap.