GoSuda

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

By snowmerak views ...

Обзор

При создании 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. Возврат подходящего кода ошибки
  2. Большой объём кода для записи логов
  3. Передача понятного сообщения об ошибке

Основная часть

Возврат подходящего кода ошибки

Пункт 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.