Å håndtere HTTP-feil på en mindre tungvint måte + RFC7807

Oversikt
Når man lager et HTTP API i Go, er det mest plagsomme feilhåndteringen. Et typisk eksempel er denne koden.
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}
Hvis antallet API-er er lite, vil det ikke være særlig upraktisk å skrive koden på denne måten. Men etter hvert som antallet API-er øker og den interne logikken blir mer kompleks, vil tre forhold bli irriterende.
- Retur av passende feilkode
- Mange loggføringer
- Sending av tydelige feilmeldinger
Hovedinnhold
Retur av passende feilkode
Punkt 1, retur av passende feilkode, er riktignok en personlig frustrasjon for meg. En erfaren utvikler vil finne den passende koden og sette den inn riktig hver gang, men både jeg og mindre erfarne utviklere kan ha vanskeligheter med å bruke passende feilkoder konsekvent når logikken blir kompleks og antallet kall øker. Det finnes flere tilnærminger til dette, og den mest vanlige er å på forhånd designe API-ets logiske flyt og deretter skrive koden slik at den returnerer passende feil. Gjør slik
Men dette virker ikke som den optimale løsningen for en menneskelig utvikler som har hjelp av IDE (eller Language Server). Ettersom REST API i seg selv utnytter betydningen i feilkodene maksimalt, kan man foreslå en annen tilnærming. Man lager en ny implementasjon av grensesnittet error, kalt HttpError, som lagrer StatusCode og Message. Deretter tilbyr man følgende hjelpefunksjon.
1err := httperror.BadRequest("wrong format")
Hjelpefunksjonen BadRequest vil returnere en HttpError der StatusCode er satt til 400 og Message til verdien som sendes inn som argument. I tillegg kan man selvsagt lage og finne hjelpefunksjoner som NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired og lignende via autofullføring. Dette er raskere enn å se opp i et ferdig designdokument, og det er mer pålitelig enn å skrive feilkoden som tall hver gang. Står ikke alt i konstantene http.StatusCode? Hysj
Antall loggføringer
Ved feil vil man naturligvis ønske å logge. Når man logger at et API er kalt og om forespørselen lyktes eller mislyktes, blir det mange linjer kode om man skal logge ved alle mulige avslutningspunkter fra starten. Ved å pakke inn selve handleren kan man håndtere dette sentralt.
Følgende er et eksempel på innpakning av chi-ruteren.
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}
Ruterstrukturen inneholder chi.Router internt, slik at funksjonaliteten til chi.Router kan brukes direkte. I metoden Get sjekkes det om hjelpefunksjonen som er foreslått ovenfor returnerer en HttpError-struct; i så fall returneres den på riktig måte. Når det er en annen error, sendes den samlet til feiltilbakekallingsfunksjonen. Denne tilbakekallingen mottas via konstruktøren.
Følgende er kode skrevet med denne pakken.
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}
Oppsummert: Hvis man bare lager HttpError via hjelpefunksjoner og returnerer den, kan man i øverste nivå svare med passende feilkode og melding, samtidig som man registrerer en tilbakekalling som logger på en måte som passer for hver tjeneste. Om nødvendig kan man utvide dette med for eksempel RequestID for mer detaljert loggføring.
Sending av tydelige feilmeldinger
For dette finnes dokumentet RFC7807. RFC7807 definerer og bruker hovedsakelig følgende elementer.
type: en URI som identifiserer feiltypen. Dette er primært et dokument som beskriver feilen.title: en kort, ettlinjes beskrivelse av hvilken feil det er.status: samme som HTTP Status Code.detail: en detaljert beskrivelse av feilen som er lesbar for mennesker.instance: URI-en der feilen oppstod. For eksempel, hvis feilen oppstod vedGET /user/info, vil verdien være/user/info.extensions: tilleggselementer i form av et JSON-objekt som beskriver feilen nærmere.- For eksempel kan brukerens input inngå når det gjelder
BadRequest. - Eller man kan inkludere tidspunktet for den siste forespørselen når det gjelder
TooManyRequest.
- For eksempel kan brukerens input inngå når det gjelder
For å gjøre dette enkelt å bruke, lager man en ny fil i pakken httperror, samme sted som HttpError, oppretter en struct RFC7807Error og gjør den tilgjengelig for konstruksjon med method chaining.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Standardtype i henhold til 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 { ... }
Type-verdien "about:blank" er standardverdi. Den betyr en side som ikke finnes. Nedenfor er et eksempel på å lage en feil for ugyldig forespørsel.
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")
Med enkel method chaining kan man lage en strukturert feilmelding for brukeren. Videre kan man støtte følgende metode for å bruke den sentraliserte ruteren som ble laget tidligere.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Hvis marshaling mislykkes, bruk bare detail
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Ved å bruke dette direkte og endre eksempelet ovenfor, får man følgende.
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}
Konklusjon
Ved å håndtere feil via en sentralisert ruter, blir det mindre arbeid å sjekke feilkoder og skrive passende feilmeldinger hver gang. Ved å bruke RFC7807 til å gi strukturerte feilmeldinger, hjelper man klienten med å forstå og håndtere feilen. Med disse metodene kan feilhåndteringen i HTTP API-er skrevet i Go gjøres enklere og mer konsistent.
Koden i denne artikkelen finnes i repositoriet gosuda/httpwrap.