GoSuda

Jak s menším úsilím zpracovávat chyby HTTP + RFC7807

By snowmerak views ...

Přehled

Při vytváření HTTP API v jazyce Go je nejotravnějším úkolem zpracování chyb. Typicky vypadá kód takto:

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 je API jen několik, není na psaní kódu tímto způsobem nic zvlášť nepohodlného. Jak ale počet API roste a vnitřní logika se komplikuje, začnou vadit tři věci.

  1. Vracení vhodných chybových kódů
  2. Množství zápisů do logu
  3. Zasílání jasných chybových zpráv

Hlavní část

Vracení vhodných chybových kódů

Bod 1, vracení vhodných chybových kódů, je sice spíše mou osobní stížností. Zkušený vývojář najde vhodný kód a pokaždé ho správně doplní. Já i ostatní méně zkušení vývojáři však můžeme mít potíže s pravidelným používáním vhodných chybových kódů, jakmile je logika složitější a počet volání roste. Existuje k tomu řada přístupů, nejtypičtějším je předem navrhnout tok logiky API a poté psát kód, který vrací vhodné chyby. Udělejte tak

Tento přístup se však nejeví jako optimální pro lidského vývojáře, který využívá pomoc IDE (nebo Language Serveru). Jelikož REST API samo o sobě co nejvíce využívá význam obsažený v chybových kódech, lze navrhnout i jiný způsob. Vytvoříme novou implementaci rozhraní error nazvanou HttpError, která uchovává StatusCode a Message. Dále poskytneme pomocné funkce:

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

Pomocná funkce BadRequest vrátí HttpError, jehož StatusCode je nastaven na 400 a Message na předanou hodnotu. Kromě ní lze samozřejmě přidat i funkce NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired atd., které lze vyhledávat a doplňovat pomocí automatického doplňování. Je to rychlejší než pokaždé kontrolovat připravený návrh a stabilnější než ručně zadávat číselné chybové kódy. Tvrdíte, že vše je v konstantách http.StatusCode? Ššš

Množství zápisů do logu

Při výskytu chyby se samozřejmě zapisuje log. Pokud by se při zaznamenávání volání API a toho, zda požadavek uspěl nebo selhal, zapisoval log na každém očekávaném místě ukončení od začátku, vzrostlo by množství kódu. Proto lze handler obalit a správu logování soustředit na jedno místo.

Následuje příklad obalení routeru 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 chi.Router, takže se používají přímo funkce chi.Router. V metodě Get se kontroluje, zda byl vrácen HttpError vytvořený výše navrženými pomocnými funkcemi, a podle toho se vhodně odpovídá. Pokud jde o error, předá se jednotně chybové callback funkci. Tento callback se předává přes konstruktor.

Následuje kód napsaný 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}

Co si o tom myslíte? Pokud pouze vytvoříte HttpError pomocnou funkcí a vrátíte ho, může nadřazený rozsah odpovědět vhodným chybovým kódem a zprávou, zatímco každá implementovaná služba může zaregistrovat callback, který zapíše vhodný log. Pokud je to potřeba, lze to dále rozšířit o RequestID apod. pro podrobnější logování.

Zasílání jasných chybových zpráv

K tomu slouží dokument RFC7807. RFC7807 obvykle definuje následující prvky:

  • type: URI identifikující typ chyby. Obvykle je to dokument popisující danou chybu.
  • title: jednořádkový popis toho, o jakou chybu jde.
  • status: shodný s HTTP stavovým kódem.
  • detail: podrobný popis chyby čitelný pro člověka.
  • instance: URI, na kterém chyba nastala. Například pokud chyba nastala při GET /user/info, hodnotou bude /user/info.
  • extensions: doplňkové prvky pro popis chyby ve formě JSON objektu.
    • Například u BadRequest může obsahovat uživatelský vstup.
    • Nebo u TooManyRequest může obsahovat čas nejnovějšího požadavku.

Pro snadné použití vytvoříme v balíčku httperror, kde se nachází HttpError, nový soubor, definujeme strukturu RFC7807Error a umožníme její vytváření pomocí vzoru řetězení metod.

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

Hodnota "about:blank" u Type je výchozí. Označuje stránku, která neexistuje. Níže je příklad vytvoření chyby pro neplatný 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. Abychom mohli využít výše vytvořený centralizovaný router, lze podporovat také následující metodu:

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

Použitím této metody upravíme předchozí příklad takto:

 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

Pokud se chyby zpracovávají pomocí tohoto centralizovaného routeru, ulehčí se zátěž spojená s kontrolou chybových kódů a psaním vhodných chybových zpráv při každém volání. Využitím RFC7807 pro poskytování strukturovaných chybových zpráv navíc může klient chybu snáze pochopit a zpracovat. Tímto způsobem lze zpracování chyb v HTTP API napsaném v jazyce Go učinit jednodušším a konzistentnějším.

Kód z tohoto článku lze najít v repozitáři gosuda/httpwrap.