GoSuda

Obsługa błędów HTTP w sposób mniej uciążliwy + RFC7807

By snowmerak views ...

Przegląd

Podczas tworzenia API HTTP w języku Go najbardziej uciążliwa jest obsługa błędów. Typowym przykładem jest poniższy kod.

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}

Jeżeli API jest nieliczne, pisanie w ten sposób nie sprawia szczególnych trudności. Wraz ze wzrostem liczby API i komplikacją logiki wewnętrznej zaczynają jednak przeszkadzać trzy kwestie:

  1. Zwracanie odpowiedniego kodu błędu
  2. Duża liczba zapisów logów wyników
  3. Przesyłanie czytelnego komunikatu o błędzie

Część główna

Zwracanie odpowiedniego kodu błędu

Punkt 1, czyli zwracanie odpowiedniego kodu błędu, jest oczywiście moją osobistą uciążliwością. Doświadczony programista zawsze znajdzie właściwy kod i wstawi go poprawnie, jednak ja, podobnie jak mniej doświadczeni programiści, mogę mieć trudności z konsekwentnym stosowaniem odpowiednich kodów błędów, gdy logika staje się złożona, a liczba wywołań rośnie. Istnieje wiele podejść do tego problemu, a najbardziej typowym jest wcześniejsze zaprojektowanie przepływu logiki API i napisanie kodu, który zwraca właściwy błąd. Proszę tak zrobić

Nie wydaje się jednak, aby było to optymalne rozwiązanie dla programisty-człowieka, który korzysta z pomocy IDE (lub Language Server). Ponadto, ponieważ samo REST API maksymalnie wykorzystuje znaczenie zawarte w kodach błędów, można zaproponować inne podejście. Należy utworzyć nową implementację interfejsu błędu (error) o nazwie HttpError, która przechowuje StatusCode oraz Message. Następnie udostępnia się funkcje pomocnicze, takie jak poniższa.

1err := httperror.BadRequest("wrong format")

Funkcja pomocnicza BadRequest zwróci HttpError, w którym StatusCode ma wartość 400, a Message zostaje ustawiony na przekazany argument. Oprócz tego można oczywiście wyszukiwać i dodawać funkcje pomocnicze, takie jak NotImplement, ServiceUnavailable, Unauthorized czy PaymentRequired, korzystając z autouzupełniania. Jest to szybsze niż każdorazowe sprawdzanie przygotowanego schematu projektu i bardziej niezawodne niż ręczne wpisywanie kodów liczbowych. Mówicie, że wszystko znajduje się w stałych http.StatusCode? Ciii

Duża liczba zapisów logów wyników

W przypadku wystąpienia błędu oczywiście zapisuje się log. Przy rejestrowaniu wywołania API oraz tego, czy żądanie zakończyło się powodzeniem, czy niepowodzeniem, zapisywanie logów we wszystkich przewidywanych punktach zakończenia, od samego początku, znacząco zwiększa liczbę pisanego kodu. Rozwiązaniem jest owinięcie samego handlera, dzięki czemu logowanie można zarządzać centralnie.

Poniżej znajduje się przykład owijania routera 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}

Struktura Router przechowuje wewnętrznie chi.Router, dzięki czemu można z niej korzystać z pełnym zestawem funkcji chi.Router. W metodzie Get sprawdzane jest, czy zwrócony został obiekt HttpError, zwracany przez funkcje pomocnicze zaproponowane wyżej, i odpowiednio na jego podstawie wysyłana jest odpowiedź. Jeśli zwrócony błąd jest typu error, jest on jednolicie przekazywany do funkcji zwrotnej obsługi błędów. Tę funkcję zwrotną otrzymuje się przez konstruktor.

Poniżej znajduje się kod napisany z wykorzystaniem tego pakietu.

 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}

Jak to wygląda? Wystarczy utworzyć jedynie funkcje pomocnicze zwracające HttpError, aby na poziomie nadrzędnym odsyłać odpowiedź z właściwym kodem i komunikatem błędu, a przez rejestrację funkcji zwrotnej umożliwić każdej usłudze zapisywanie odpowiednich logów. W razie potrzeby można to dodatkowo rozszerzyć, na przykład przez użycie RequestID, co pozwoli na szczegółowe logowanie.

Przesyłanie czytelnego komunikatu o błędzie

Dokumentem określającym standard w tym zakresie jest RFC7807. RFC7807 definiuje i wykorzystuje głównie następujące elementy:

  • type: URI identyfikujący typ błędu. Zazwyczaj jest to dokument opisujący dany błąd.
  • title: jednozdaniowy opis przedstawiający rodzaj błędu.
  • status: taki sam jak kod statusu HTTP.
  • detail: szczegółowy opis danego błędu, czytelny dla człowieka.
  • instance: URI, w którym wystąpił błąd. Na przykład jeśli błąd wystąpił przy GET /user/info, wartością będzie /user/info.
  • extensions: dodatkowe elementy opisujące błąd, tworzone jako obiekt JSON.
    • Na przykład w przypadku BadRequest mogą zostać uwzględnione dane wejściowe użytkownika.
    • Alternatywnie w przypadku TooManyRequest można uwzględnić czas ostatniego żądania.

Aby ułatwić korzystanie z tego standardu, w pakiecie httperror, w tym samym miejscu co HttpError, tworzy się nowy plik, definiuje się strukturę RFC7807Error i umożliwia jej tworzenie za pomocą wzorca łańcuchowego wywołania metod.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Domyślny typ zgodnie z 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 { ... }

Wartość "about:blank" pola Type jest wartością domyślną. Oznacza ona brak strony. Poniżej znajduje się przykład utworzenia błędu dla nieprawidłowego żądania.

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")

Dzięki prostemu łańcuchowemu wywoływaniu metod można wygenerować ustrukturyzowany komunikat o błędzie dla użytkownika. Aby ponadto korzystać z wcześniej przedstawionego scentralizowanego routera, można udostępnić następującą metodę.

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Jeśli serializacja się nie powiedzie, użyj jedynie pola detail
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Po zastosowaniu tej metody przykład powyżej przyjmuje następującą postać.

 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}

Zakończenie

Dzięki obsłudze błędów z wykorzystaniem scentralizowanego routera zmniejsza się obciążenie związane z każdorazowym sprawdzaniem kodów błędów i pisaniem odpowiednich komunikatów. Ponadto, wykorzystując RFC7807 do dostarczania ustrukturyzowanych komunikatów o błędach, można pomóc klientom w zrozumieniu błędu i jego obsłudze. Dzięki tym metodom obsługa błędów w HTTP API napisanym w języku Go może stać się prostsza i bardziej spójna.

Kod z tego artykułu można znaleźć w repozytorium gosuda/httpwrap.