GoSuda

Менее обременительная обработка ошибок HTTP + RFC7807

By snowmerak
views ...

개요

Go 언어에서 http api를 생성할 때, 가장 귀찮은 건 에러 처리입니다. 대표적으로 이런 코드가 있습니다.

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 자체가 에러 코드에 담긴 의미를 максимально 활용하는 만큼, 또 다른 방식을 제안할 수 있을 겁니다. HttpError라는 에러(error) 인터페이스 구현체를 새로 만들어, StatusCodeMessage를 저장하게 합니다. И предоставляет следующие вспомогательные функции.

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		// Если обработчик возвращает ошибку
32		if err := handler(writer, request); err != nil {
33			he := &httperror.HttpError{}
34			// Проверяем, является ли ошибка HttpError
35			switch errors.As(err, &he) {
36			case true:
37				// Если это HttpError, используем его сообщение и код
38				http.Error(writer, he.Message, he.Code)
39			case false:
40				// В противном случае, используем общее сообщение об ошибке сервера
41				http.Error(writer, err.Error(), http.StatusInternalServerError)
42			}
43			// Вызываем колбэк для обработки ошибки
44			r.errCallback(err)
45		}
46	})
47}

Структура Router содержит 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 Object.
    • Например, в случае 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		// Если маршалинг не удался, возвращаемся к использованию только детали
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.