Zpracování chyb HTTP s menší mírou nepříjemnosti + RFC7807
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.
- Vrácení adekvátního chybového kódu
- Vysoký objem zápisu logů výsledků
- 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řiGET /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.
- Například v případě
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.