GoSuda

Zpracování chyb HTTP s menší mírou nepříjemnosti + RFC7807

By snowmerak
views ...

Přehled

Při generování http api v jazyce Go je nejotravnější částí zpracování chyb. Typickým příkladem je následující kód.

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}

Pokud počet API není velký, psaní tímto způsobem nemusí být nijak zvlášť nepohodlné. Nicméně, s rostoucím počtem API a komplikovanější vnitřní logikou začínají být patrné tři nepříjemnosti.

  1. Vrácení adekvátního chybového kódu
  2. Vysoký objem zápisu logů výsledků
  3. Odesílání srozumitelných chybových zpráv

Jádro

Vrácení adekvátního chybového kódu

Samozřejmě, bod 1, tedy vrácení adekvátního chybového kódu, je mou osobní výhradou. Zkušení vývojáři jistě dokáží pokaždé vyhledat a správně implementovat náležité kódy, avšak jak se logika stává složitější a frekvence se zvyšuje, i já a méně zkušení vývojáři můžeme pociťovat potíže s konzistentním používáním vhodných chybových kódů. Existuje pro to několik metod, přičemž nejtypičtější je předběžné navržení toku logiky API a následné napsání kódu tak, aby vracel odpovídající chyby. Učiňte takto

Avšak toto se nejeví jako optimální metoda pro lidského vývojáře, který je asistován IDE (či Language Serverem). Jelikož REST API samo o sobě maximálně využívá význam obsažený v chybových kódech, je možné navrhnout alternativní přístup. Můžeme vytvořit novou implementaci rozhraní error nazvanou HttpError, která bude ukládat StatusCode a Message. A poskytneme následující pomocnou funkci.

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

Pomocná funkce BadRequest vrátí instanci HttpError, kde StatusCode bude nastaven na 400 a Message na hodnotu předanou jako argument. Kromě toho by mělo být samozřejmě možné vyhledávat a přidávat pomocné funkce jako NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired pomocí funkce automatického doplňování. Toto je rychlejší než neustálá kontrola připravené specifikace a stabilnější než opakované zadávání chybových kódů v číselné podobě. Vše je přece v konstantách http.StatusCode? Ticho

Vysoký objem zápisu logů výsledků

Při vzniku chyby je samozřejmě nutné zanechat logovací záznam. Při logování informací o tom, zda bylo API voláno a zda požadavek byl úspěšný či neúspěšný, se počet kódu určeného k zápisu zvyšuje, pokud se loguje od samého počátku do všech předpokládaných koncových bodů. To lze spravovat centrálně tím, že se samotný handler jednou obalí.

Následuje příklad obalující router 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 routeru obsahuje interně chi.Router, je tedy konfigurována tak, aby využívala funkce chi.Router beze změny. Při pohledu na metodu Get zjistíte, že po kontrole, zda byla vrácena struktura HttpError navržená výše, je adekvátně odeslána odpověď, a v případě typu error je tato chyba hromadně předána do chybové callback funkce. Tento callback je přijímán prostřednictvím konstruktoru.

Následující kód demonstruje implementaci s využitím tohoto balíčku.

 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ý je výsledek? Pouhým vytvořením pomocné funkce pro HttpError a jejím vrácením můžeme zajistit, že nadřazená úroveň (scope) odešle odpověď s náležitým chybovým kódem a zprávou, a zároveň můžeme zaregistrováním callbacku zpracovat odpovídající logování pro každou implementovanou službu. Pokud by byla potřeba dodatečná rozšíření, je možné implementovat detailnější logování s využitím prvků jako je RequestID.

Odesílání srozumitelných chybových zpráv

Dokumentací pro tento účel je RFC7807. RFC7807 definuje a využívá primárně následující komponenty:

  • type: URI identifikující typ chyby. Jedná se primárně o dokumentaci popisující danou chybu.
  • title: Jednořádkový popis toho, o jakou chybu se jedná.
  • status: Odpovídá HTTP Status Code.
  • detail: Podrobný popis chyby čitelný pro člověka.
  • instance: URI, kde k chybě došlo. Například, pokud chyba nastala při GET /user/info, tato hodnota bude /user/info.
  • extensions: Dodatečné prvky tvořené jako JSON Object pro vysvětlení chyby.
    • Například v případě BadRequest může obsahovat vstup uživatele.
    • Nebo v případě TooManyRequest může zahrnovat čas poslední žádosti.

Pro snadné použití vytvoříme nový soubor v balíčku httperror (na stejném místě jako HttpError), vytvoříme strukturu RFC7807Error a umožníme její konstrukci pomocí vzoru řetězení metod (method chaining).

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Výchozí typ dle 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 { ... }

Výchozí hodnotou pro Type je "about:blank", což značí neexistující stránku. Níže je uveden příklad vytvoření chyby pro nesprávný požadavek.

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

Pomocí jednoduchého řetězení metod lze vytvořit strukturovanou chybovou zprávu pro uživatele. Dále, pro využití výše navrženého centralizovaného routeru, může být podporována následující metoda.

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Pokud selže serializace, použije se jako záložní řešení pouze detail
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Použitím tohoto přímo a modifikací výše uvedeného příkladu získáme následující výsledek.

 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}

Závěr

Zpracováním chyb pomocí centralizovaného routeru, jak je ukázáno výše, lze snížit zátěž spojenou s ověřováním chybových kódů a formulováním náležitých chybových zpráv. Dále, využitím RFC7807 můžeme poskytnout strukturované chybové zprávy, což klientům pomůže při porozumění a zpracování chyb. Tímto přístupem lze učinit zpracování chyb v HTTP API napsaných v jazyce Go snazším a konzistentnějším.

Kód z tohoto článku je dostupný v repozitáři gosuda/httpwrap.