Att hantera HTTP-fel på ett smidigare sätt + RFC7807

Översikt
När man skapar ett HTTP API i Go är felhanteringen det mest besvärliga. Ett typiskt exempel ser ut så här.
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}
Om antalet API:er är litet går det att skriva på det här sättet utan att det känns särskilt besvärligt. När antalet API:er ökar och den interna logiken blir mer komplex blir dock tre saker irriterande.
- Att returnera lämpliga felkoder
- Antalet loggrader som måste skrivas
- Att skicka tydliga felmeddelanden
Huvuddel
Att returnera lämpliga felkoder
Att returnera lämpliga felkoder är visserligen ett personligt klagomål från min sida. En erfaren utvecklare hittar säkert rätt kod och använder den konsekvent varje gång, men jag och andra mindre erfarna utvecklare kan ha svårt att konsekvent använda rätt felkod när logiken blir komplex och antalet anrop ökar. Det finns flera sätt att hantera detta, och det vanligaste är att i förväg designa API:ts logikflöde och sedan skriva koden så att rätt fel returneras. Gör så här
Detta verkar dock inte vara det optimala tillvägagångssättet för en mänsklig utvecklare som får hjälp av en IDE (eller en Language Server). Eftersom REST API:er i sig utnyttjar den betydelse som ligger i felkoderna så fullt som möjligt, kan man dessutom föreslå ett annat tillvägagångssätt. Skapa en ny implementation av gränssnittet error, kallad HttpError, som lagrar StatusCode och Message. Tillhandahåll sedan följande hjälpfunktioner.
1err := httperror.BadRequest("wrong format")
Hjälpfunktionen BadRequest returnerar ett HttpError där StatusCode är satt till 400 och Message till det värde som skickats som argument. Utöver detta kan man naturligtvis lägga till och söka efter hjälpfunktioner som NotImplement, ServiceUnavailable, Unauthorized och PaymentRequired med hjälp av autokomplettering. Detta går snabbare än att kontrollera en förberedd designspecifikation varje gång, och det är mer tillförlitligt än att skriva in felkoden som en siffra varje gång. Säger du att allt finns i konstanterna i http.StatusCode? Shh
Antalet loggrader som måste skrivas
Vid fel loggar man naturligtvis. När man loggar om ett API har anropats och om en begäran lyckades eller misslyckades, blir det mycket kod om man skriver loggar vid varje förväntad slutpunkt från början. Genom att omsluta själva handlaren kan man i stället hantera detta centralt.
Följande är ett exempel där chi-routern omsluts.
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 innehåller chi.Router internt, så att chi.Router:s funktioner kan användas direkt. I metoden Get kontrolleras om det returnerade värdet är en HttpError-struktur, som returneras av de hjälpfunktioner vi just föreslog, och den returneras på lämpligt sätt. Om det är ett error skickas det enhetligt vidare till felcallbacken. Denna callback tas emot via konstruktorn.
Följande är kod skriven med hjälp av detta paket.
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}
Vad tycker du? Om man bara skapar HttpError som hjälpfunktioner och returnerar dem, kan den övre nivån svara med lämplig felkod och ett lämpligt meddelande, och man kan registrera en callback för att skriva lämpliga loggar i varje tjänst. Vid behov kan detta utökas med till exempel RequestID för mer detaljerad loggning.
Att skicka tydliga felmeddelanden
För detta finns dokumentet RFC7807. RFC7807 definierar och använder huvudsakligen följande element.
type: en URI som identifierar feltypen. Det är i huvudsak ett dokument som beskriver felet.title: en kort, enradig beskrivning av vilket fel det är.status: samma som HTTP-statuskoden.detail: en detaljerad beskrivning av felet som kan läsas av människor.instance: URI:n där felet uppstod. Om felet till exempel uppstod vidGET /user/infoblir värdet/user/info.extensions: ett sekundärt element i form av ett JSON-objekt för att beskriva felet närmare.- Till exempel kan användarens indata ingå vid
BadRequest. - Eller så kan den senaste förfrågningstidpunkten inkluderas vid
TooManyRequest.
- Till exempel kan användarens indata ingå vid
För att göra detta enkelt att använda skapar man en ny fil i paketet httperror, samma plats som HttpError, definierar en struktur RFC7807Error och låter den skapas med ett metodkedjemönster.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Standardtyp enligt 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" för Type är standardvärdet. Det betyder en sida som inte finns. Nedan följer ett exempel på att skapa ett fel för en felaktig begäran.
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 metodkedjning kan man skapa strukturerade felmeddelanden för användaren. För att även kunna använda den centraliserade routern som skapades tidigare kan man dessutom stödja följande metod.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Om marshalling misslyckas, använd endast detail
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Om vi använder detta direkt och ändrar exemplet ovan blir det så här.
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}
Slutsats
Genom att hantera fel med en centraliserad router minskar belastningen av att kontrollera felkoder och skriva lämpliga felmeddelanden varje gång. Genom att använda RFC7807 för att skicka strukturerade felmeddelanden kan man dessutom hjälpa klienten att förstå och hantera felen. På så sätt kan felhanteringen i HTTP API:er skrivna i Go göras enklare och mer konsekvent.
Koden i den här artikeln finns i förvaringsplatsen gosuda/httpwrap.