Het afhandelen van HTTP-fouten op een minder vervelende manier + RFC7807

Overzicht
Bij het maken van een HTTP API in de Go-taal is foutafhandeling het meest vervelend. Een typisch voorbeeld van zo'n code is het volgende.
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}
Als er maar enkele API's zijn, levert het schrijven op deze manier weinig problemen op. Naarmate het aantal API's toeneemt en de interne logica complexer wordt, gaan echter drie zaken storen.
- Het teruggeven van een passende foutcode
- Het grote aantal regels voor logging
- Het verzenden van een duidelijke foutmelding
Hoofdtekst
Het teruggeven van een passende foutcode
Het eerste punt, het teruggeven van een passende foutcode, is weliswaar een persoonlijke klacht van mij. Ervaren ontwikkelaars zoeken de juiste code en vullen die steeds correct in, maar ik en ook minder ervaren ontwikkelaars kunnen het moeilijk vinden om consequent de juiste foutcode te gebruiken naarmate de logica complexer wordt en het aantal aanroepen toeneemt. Hiervoor bestaan verschillende oplossingen, waarvan de meest voor de hand liggende is om de logische stroom van de API vooraf te ontwerpen en vervolgens de code zo te schrijven dat de juiste fout wordt teruggegeven. Doe dat maar
Dit lijkt echter niet de optimale aanpak voor een menselijke ontwikkelaar die gebruikmaakt van een IDE (of Language Server). Omdat de REST API zelf de betekenis van foutcodes zo goed mogelijk benut, kan ik een andere methode voorstellen. Maak een nieuwe implementatie van de error-interface met de naam HttpError, die StatusCode en Message opslaat. Stel daarnaast de volgende hulpfuncties beschikbaar.
1err := httperror.BadRequest("wrong format")
De hulpfunctie BadRequest retourneert een HttpError waarvan StatusCode is ingesteld op 400 en Message op de meegegeven waarde. Daarnaast kunnen hulpfuncties zoals NotImplement, ServiceUnavailable, Unauthorized en PaymentRequired uiteraard via automatische aanvulling worden opgezocht en toegevoegd. Dit is sneller dan telkens het ontwerpdocument raadplegen, en betrouwbaarder dan elke keer een numerieke foutcode invoeren. Dat staat allemaal in de http.StatusCode-constanten? Sst
Het aantal logregels
Bij een fout wordt er uiteraard een logregel geschreven. Wanneer je logt of een API-aanroep is geslaagd of mislukt, betekent het vanaf het begin loggen op elk verwacht eindpunt dat er veel code moet worden geschreven. Door de handler zelf te omhullen, kan de logging centraal worden beheerd.
Het volgende is een voorbeeld van het omhullen van de chi-router.
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}
De routerstruct bevat een chi.Router intern, zodat de functionaliteit van chi.Router ongewijzigd beschikbaar blijft. In de methode Get wordt gecontroleerd of de HttpError-struct die door de hulpfuncties die ik hierboven heb voorgesteld wordt geretourneerd. Die wordt dan op de juiste manier teruggegeven. In alle andere gevallen, waarin het een gewone error is, wordt deze gezamenlijk doorgegeven aan de foutcallbackfunctie. Deze callback wordt via de constructor ontvangen.
Het volgende is code die dit pakket gebruikt.
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}
Wat vindt u ervan? Als je alleen een HttpError maakt via een hulpfunctie en die retourneert, kan de bovenliggende scope antwoorden met een passende foutcode en foutmelding, terwijl je via een geregistreerde callback in elke service een passende log kunt bijhouden. Indien nodig kan dit verder worden uitgebreid, bijvoorbeeld met een RequestID, zodat gedetailleerde logging mogelijk wordt.
Het verzenden van duidelijke foutmeldingen
Hiervoor bestaat het document RFC7807. RFC7807 definieert en gebruikt voornamelijk de volgende elementen.
type: een URI die het fouttype identificeert. Meestal is dit een document dat de fout beschrijft.title: een beknopte omschrijving van de aard van de fout in één regel.status: komt overeen met de HTTP-statuscode.detail: een gedetailleerde beschrijving van de fout die door mensen leesbaar is.instance: de URI waarin de fout is opgetreden. Als de fout bijvoorbeeld optrad bijGET /user/info, dan is de waarde/user/info.extensions: een aanvullend element om de fout nader te beschrijven, opgebouwd als JSON-object.- Bij een
BadRequestkan bijvoorbeeld de invoer van de gebruiker worden opgenomen. - Bij een
TooManyRequestkan bijvoorbeeld ook het tijdstip van het meest recente verzoek worden opgenomen.
- Bij een
Om dit eenvoudig te gebruiken, maak je in het pakket httperror, op dezelfde locatie als HttpError, een nieuw bestand aan, definieer je de struct RFC7807Error en maak je het mogelijk om deze op te bouwen via een methodeketen.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Standaardtype volgens 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 { ... }
De "about:blank" van Type is de standaardwaarde. Deze betekent een niet-bestaande pagina. Hieronder volgt een voorbeeld van het maken van een fout voor een ongeldig verzoek.
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")
Met eenvoudige methodeketens kan een gestructureerde foutmelding voor de gebruiker worden gemaakt. Om de eerder geschreven gecentraliseerde router te gebruiken, kan ook de volgende methode worden ondersteund.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Als marshaling mislukt, gebruik dan alleen de detail
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Als je dit direct gebruikt en het bovenstaande voorbeeld hiermee aanpast, ziet het er zo uit.
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}
Conclusie
Door fouten af te handelen via een gecentraliseerde router, wordt de last om bij elke aanroep de foutcode te controleren en een passende foutmelding te schrijven kleiner. Door RFC7807 te gebruiken om gestructureerde foutmeldingen te leveren, helpt u de client bovendien om de fout te begrijpen en ermee om te gaan. Op deze manier kunnen we de foutafhandeling van HTTP API's die in Go zijn geschreven eenvoudiger en consistenter maken.
De code van dit artikel is te vinden in de repository gosuda/httpwrap.