GoSuda

Zefektívnenie spracovania chýb HTTP + RFC7807

By snowmerak
views ...

Zhrnutie

Pri vytváraní http api v jazyku Go je najotravnejšou záležitosťou správa chýb (error handling). Reprezentatívny kód vyzerá nasledovne.

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}

Ak nie je počet API nízky, písanie týmto spôsobom nemusí byť vôbec nepríjemné. Avšak, so zvyšujúcim sa počtom API a rastúcou komplexitou vnútornej logiky začnú tri aspekty prekážať.

  1. Vrátenie adekvátneho chybového kódu
  2. Množstvo zápisov logov výsledkov
  3. Odosielanie jasných chybových správ

Jadro témy

Vrátenie adekvátneho chybového kódu

Samozrejme, bod 1, vrátenie adekvátneho chybového kódu, je mojou osobnou výhradou. Skúsený vývojár by si vždy našiel a správne vložil adekvátny kód, avšak vývojári ako ja, ktorí sú stále menej zbehlí, môžu pociťovať ťažkosti pri pravidelnom používaní vhodných chybových kódov s rastúcou komplexnosťou logiky a zvyšujúcou sa frekvenciou volaní. Existuje na to viacero metód, pričom najtypickejšou je predbežné navrhnutie toku logiky API a následné napísanie kódu tak, aby vracal príslušnú chybu. Urobte to takto

Avšak, toto sa nejaví ako optimálna metóda pre ľudského vývojára, ktorému asistuje IDE (prípadne Language Server). Keďže REST API samo o sebe maximálne využíva význam obsiahnutý v chybovom kóde, je možné navrhnúť iný prístup. Vytvoríme novú implementáciu rozhrania chyby (error) s názvom HttpError, ktorá bude ukladať hodnoty StatusCode a Message. A k tomu ponúkame nasledujúcu pomocnú funkciu (helper function).

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

Pomocná funkcia BadRequest vráti objekt HttpError, kde bude StatusCode nastavený na 400 a Message na hodnotu prijatú ako argument. Okrem toho budú samozrejme dostupné na vyhľadanie a pridanie prostredníctvom funkcie automatického dopĺňania aj pomocné funkcie ako NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired a podobné. To je rýchlejšie ako neustále kontrolovať pripravenú špecifikáciu a pravdepodobne aj spoľahlivejšie, než manuálne zadávať chybové kódy ako číselné hodnoty. Všetko je to v konštantách http.StatusCode? Ticho

Množstvo zápisov logov výsledkov

Pri vzniku chyby sa štandardne zaznamenáva log. Ak sa má logovať, či bolo API volané, a či bol požadovaný úspešný alebo neúspešný, zaznamenávanie logu v každom očakávanom bode ukončenia od samého začiatku zvyšuje množstvo kódu, ktorý je potrebné napísať. Toto je možné centrálne spravovať jednorazovým obalením samotného handleru.

Nasleduje príklad obalenia (wrapping) 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}

Štruktúra Router obsahuje internú inštanciu chi.Router, a je teda navrhnutá tak, aby priamo využívala funkcie chi.Router. Ak sa pozriete na metódu Get, kontroluje, či bola vrátená štruktúra HttpError vygenerovaná vyššie navrhnutou pomocnou funkciou, a adekvátne reaguje; v prípade všeobecnej error je táto chyba uniformne odoslaná do chybovej callback funkcie. Tento callback je prijatý prostredníctvom konštruktora.

Nasledujúci kód demonštruje využitie tohto balíka.

 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}

Čo poviete? Jednoduchým vytvorením a vrátením HttpError ako pomocnej funkcie je možné túto situáciu riešiť registráciou callbacku, ktorý zaistí, že na vyššej úrovni sa odpovie so zodpovedajúcim chybovým kódom a správou, pričom sa pre každú implementovanú službu zaznamená patričný log. Ak to bude dodatočne nutné, je možné systém rozšíriť a umožniť tak detailné logovanie s využitím atribútov ako napríklad RequestID.

Odosielanie jasných chybových správ

Na tento účel slúži štandard RFC7807. RFC7807 definuje a využíva predovšetkým nasledujúce prvky:

  • type: URI identifikujúci typ chyby. Zvyčajne ide o dokument popisujúci danú chybu.
  • title: Jednovetový popis toho, o akú chybu ide.
  • status: Zhodné s HTTP Status Code.
  • detail: Podrobný popis danej chyby čitateľný pre človeka.
  • instance: URI, kde chyba nastala. Napríklad, ak chyba nastala pri GET /user/info, hodnota bude /user/info.
  • extensions: Doplnkové prvky na popis chyby, ktoré sú štruktúrované ako JSON Object.
    • Napríklad, v prípade BadRequest môžu byť zahrnuté vstupy od používateľa.
    • Alebo v prípade TooManyRequest môže byť zahrnutý čas posledného požiadavku.

Pre jednoduché použitie sa v balíku httperror, ktorý je na rovnakej úrovni ako HttpError, vytvorí nový súbor, kde sa definuje štruktúra RFC7807Error, ktorú bude možné vytvárať pomocou vzoru metódového reťazenia (method chaining pattern).

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Predvolený typ podľa 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" pre Type predstavuje predvolenú hodnotu. Označuje neexistujúcu stránku. Nižšie je uvedený príklad vytvorenia chyby pre nesprávny požiadavok.

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

Pomocou jednoduchého metódového reťazenia je možné vygenerovať štruktúrované chybové správy pre používateľa. Taktiež, na využitie vyššie spomínaného centralizovaného routera, je možné podporiť nasledujúcu metódu:

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Ak zlyhá marshaling, vráti sa detail ako záloha
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Priamym použitím tohto mechanizmu môžeme upraviť vyššie uvedený príklad nasledovne:

 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}

Zhrnutie

Pri využití centralizovaného routera na spracovanie chýb je možné redukovať záťaž spojenú s neustálou kontrolou chybových kódov a písaním zodpovedajúcich chybových správ. Využitím RFC7807 na poskytovanie štruktúrovaných chybových správ je možné klientom pomôcť pri pochopení a spracovaní chýb. Týmito metódami je možné učiniť správu chýb v HTTP API napísanom v jazyku Go jednoduchšou a konzistentnejšou.

Kód z tohto príspevku je možné skontrolovať v repozitári gosuda/httpwrap.