Mere elegant håndtering af HTTP-fejl + RFC7807

Oversigt
Når man opretter en HTTP API i Go, er fejlhåndtering det mest besværlige. Et typisk eksempel på en sådan kode ser sådan ud.
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 der kun er få API'er, er der sandsynligvis ingen større ulempe ved at skrive koden på denne måde. Men efterhånden som antallet af API'er vokser og den interne logik bliver mere kompleks, begynder tre ting at irritere:
- Returnering af passende fejlkoder
- Det store antal logposter
- Afsendelse af tydelige fejlmeddelelser
Hovedindhold
Returnering af passende fejlkoder
Punkt 1, returnering af passende fejlkoder, er ganske vist en personlig frustration for mig. En erfaren udvikler finder altid den rette kode og indsætter den korrekt hver gang. Men jeg selv og andre mindre erfarne udviklere kan have svært ved konsekvent at anvende de rette fejlkoder, efterhånden som logikken bliver mere kompleks og antallet af kald stiger. Der findes sikkert flere metoder til dette, og den mest almindelige er at designe API'ens logiske flow på forhånd og derefter skrive koden, så den returnerer den passende fejl. Gør bare sådan
Men dette forekommer ikke at være den optimale metode for en menneskelig udvikler, der får hjælp fra en IDE (eller Language Server). Da selve REST API'et udnytter betydningen i fejlkoderne så meget som muligt, kan man desuden foreslå en anden tilgang. Man opretter en ny implementering af error-interfacet kaldet HttpError, som gemmer StatusCode og Message. Derefter stilles følgende hjælpefunktioner til rådighed.
1err := httperror.BadRequest("wrong format")
Hjælpefunktionen BadRequest returnerer en HttpError, hvor StatusCode er sat til 400, og Message er sat til den modtagne værdi. Ud over dette kan hjælpefunktioner som NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired og lignende naturligvis slås op og tilføjes via autofuldførelse. Dette er hurtigere end at slå op i et færdigt designdokument hver gang, og mere pålideligt end at indtaste fejlkoder som tal hver gang. Er det ikke alt sammen i konstanterne i http.StatusCode? Shh
Antallet af logposter
Ved fejl logger man naturligvis. Når man logger, om et API-kald er lykkedes eller mislykkedes, bliver koden længere, hvis man logger på alle forventede afslutningspunkter fra start. Ved at indpakke selve handleren kan man i stedet administrere logningen centralt.
Følgende er et eksempel, der indpakker chi-routeren.
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}
Routerstrukturen indeholder chi.Router internt, så chi.Routers funktionalitet kan anvendes direkte. I Get-metoden kontrolleres det, om der returneres en HttpError-struktur fra hjælpefunktionen, som blev foreslået ovenfor. Hvis det er tilfældet, returneres den passende fejl. Hvis det er en almindelig error, sendes den samlet til fejl-callback-funktionen. Denne callback modtages gennem konstruktøren.
Følgende er kode, der anvender denne pakke.
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}
Hvad synes du? Hvis man blot opretter HttpError via hjælpefunktioner og returnerer dem, kan det øverste scope svare med den passende fejlkode og meddelelse. Samtidig kan man registrere en callback, der logger passende for hver implementeringstjeneste. Hvis det er nødvendigt, kan dette udvides, så der kan foretages detaljeret logning ved hjælp af fx RequestID.
Afsendelse af tydelige fejlmeddelelser
Dokumentet RFC7807 er en standard til dette formål. RFC7807 definerer og anvender primært følgende elementer:
type: en URI, der identificerer fejltypen. Det er primært et dokument, der beskriver fejlen.title: en kort beskrivelse på en linje af, hvilken fejl der er tale om.status: svarer til HTTP-statuskoden.detail: en detaljeret forklaring af fejlen, læsbar for mennesker.instance: URI'en, hvor fejlen opstod. Hvis fejlen for eksempel opstod vedGET /user/info, vil værdien være/user/info.extensions: sekundære elementer i form af et JSON-objekt, der beskriver fejlen yderligere.- For eksempel kan brugerens input indgå ved
BadRequest. - Eller ved
TooManyRequestkan tidspunktet for den seneste anmodning indgå.
- For eksempel kan brugerens input indgå ved
For at gøre dette let at bruge oprettes en ny fil i pakken httperror, samme sted som HttpError. Der defineres en struktur RFC7807Error, som kan oprettes med et metodekæde-mønster.
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 { ... }
"about:blank" i Type er standardværdien. Den betyder en side, der ikke findes. Nedenfor er et eksempel på oprettelse af en fejl ved en ugyldig anmodning.
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 et simpelt metodekæde kan man oprette en struktureret fejlmeddelelse til brugeren. For at bruge den centraliserede router, der blev introduceret tidligere, kan man desuden tilbyde følgende metode.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Hvis marshaling mislykkes, anvendes blot detail
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Ved at anvende dette direkte og ændre eksemplet ovenfor bliver det til 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}
Konklusion
Når fejl håndteres via denne centraliserede router, reduceres byrden ved at skulle kontrollere fejlkoder og skrive passende fejlmeddelelser hver gang. Ved at anvende RFC7807 til at levere strukturerede fejlmeddelelser kan man desuden hjælpe klienten med at forstå og håndtere fejlene. På denne måde kan fejlhåndteringen i HTTP API'er skrevet i Go gøres enklere og mere konsistent.
Koden i denne artikel kan findes i depotet gosuda/httpwrap.