GoSuda

Minder gedoe met HTTP-fouten afhandelen + RFC7807

By snowmerak
views ...

Overzicht

Bij het creëren van een http API in de Go-taal is foutafhandeling het meest omslachtige aspect. Een typisch voorbeeld hiervan is de volgende code:

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}

Indien het aantal API's beperkt blijft, zal het schrijven van code op deze wijze waarschijnlijk geen significante ongemakken veroorzaken. Echter, naarmate het aantal API's toeneemt en de interne logica complexer wordt, manifesteren zich drie irritatiepunten:

  1. Retourneren van een geschikte error code
  2. Het grote aantal te schrijven resultaatlogboeken
  3. Versturen van een duidelijke error message

Hoofdtekst

Retourneren van een geschikte error code

Uiteraard is punt 1, het retourneren van een geschikte error code, een persoonlijke klacht. Een ervaren ontwikkelaar zal telkens de juiste code identificeren en correct implementeren; echter, onervaren ontwikkelaars, inclusief ikzelf, kunnen moeite ondervinden met het consistent toepassen van de juiste error codes naarmate de logica complexer wordt en het aantal iteraties toeneemt. Hiervoor bestaan diverse methoden, waarvan de meest voorkomende is om de API-logica vooraf te ontwerpen en vervolgens code te schrijven die de geschikte error retourneert. Doe dat dan maar

Dit lijkt echter niet de meest optimale benadering voor menselijke ontwikkelaars die ondersteuning ontvangen van een IDE (of Language Server). Bovendien, aangezien de REST API zelf de betekenis van error codes maximaal benut, kan een alternatieve methode worden voorgesteld. Creëer een nieuwe implementatie van de error interface, genaamd HttpError, die de StatusCode en Message opslaat. Bied vervolgens de volgende helperfunctie aan:

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

De BadRequest helperfunctie zal een HttpError retourneren met een StatusCode van 400 en de Message ingesteld op de als argument ontvangen waarde. Daarnaast zullen vanzelfsprekend helperfuncties zoals NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired via autocompletion kunnen worden opgevraagd en toegevoegd. Dit is sneller dan telkens het voorbereide ontwerpdocument te raadplegen en stabieler dan telkens numerieke error codes in te voeren. http.StatusCode constanten zijn allemaal beschikbaar, toch? Sssst

Het grote aantal te schrijven resultaatlogboeken

Bij het optreden van een error wordt vanzelfsprekend een logboek bijgehouden. Wanneer een API wordt aangeroepen en er logboeken worden vastgelegd over het succes of falen van de aanvraag, leidt het vastleggen van logboeken vanaf het begin tot alle verwachte eindpunten tot een aanzienlijke toename van de te schrijven code. Dit kan centraal worden beheerd door de handler zelf in te kapselen.

Hieronder volgt een voorbeeld van het omwikkelen 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 Router-structuur bevat een chi.Router intern, waardoor de functionaliteit van de chi.Router direct kan worden gebruikt. In de Get-methode wordt gecontroleerd of de HttpError-structuur, die door de zojuist voorgestelde helperfunctie wordt geretourneerd, is geretourneerd. Vervolgens wordt deze correct geretourneerd, en in geval van een error wordt deze uniform doorgegeven aan de error callback-functie. Deze callback wordt via de constructor ontvangen.

Hieronder volgt de code die met behulp van dit pakket is geschreven:

 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 hiervan? Door eenvoudigweg HttpError als helperfunctie te creëren en te retourneren, kan de bovenliggende scope een respons met de juiste error code en message terugsturen, terwijl callbacks kunnen worden geregistreerd om geschikte logboeken bij te houden voor elke geïmplementeerde service. Indien nodig kan dit verder worden uitgebreid om gedetailleerde logging mogelijk te maken met behulp van bijvoorbeeld een RequestID.

Versturen van een duidelijke error message

Voor dit doel dient RFC7807 als leidraad. RFC7807 definieert en gebruikt voornamelijk de volgende elementen:

  • type: Een URI die het type error identificeert. Dit betreft meestal een document dat de error beschrijft.
  • title: Een korte beschrijving van het type error.
  • status: Identiek aan de HTTP Status Code.
  • detail: Een gedetailleerde, voor mensen leesbare beschrijving van de betreffende error.
  • instance: De URI waarop de error is opgetreden. Indien bijvoorbeeld een error optreedt bij GET /user/info, zal /user/info de waarde zijn.
  • extensions: Secundaire elementen in JSON Object-formaat ter beschrijving van de error.
    • Bijvoorbeeld, in het geval van een BadRequest kan de invoer van de gebruiker worden opgenomen.
    • Of in het geval van TooManyRequest kan het tijdstip van de meest recente aanvraag worden opgenomen.

Om dit eenvoudig te kunnen gebruiken, wordt een nieuw bestand aangemaakt in het httperror pakket, op dezelfde locatie als HttpError. Hierin wordt de RFC7807Error structuur gegenereerd, en deze kan worden gecreëerd met behulp van een method chaining patroon.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Default type as per 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 Type "about:blank" is de standaardwaarde. Dit verwijst naar een niet-bestaande pagina. Hieronder volgt een voorbeeld van het creëren van een error voor een ongeldige aanvraag.

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

Door middel van eenvoudige method chaining kan een gestructureerde error message voor de gebruiker worden gecreëerd. Bovendien kan de volgende methode worden ondersteund voor het gebruik van de eerder beschreven gecentraliseerde router:

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// If marshaling fails, fall back to just using the detail
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Door dit direct toe te passen, wordt het voorgaande voorbeeld als volgt aangepast:

 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 errors op deze gecentraliseerde wijze af te handelen met behulp van een router, kan de last van het telkens controleren van error codes en het opstellen van geschikte error messages worden verminderd. Bovendien kan door het aanbieden van gestructureerde error messages met RFC7807 de client worden ondersteund bij het begrijpen en verwerken van errors. Deze methodologie vergemakkelijkt en uniformiseert de errorafhandeling van HTTP API's die in de Go-taal zijn geschreven.

De code van dit artikel is beschikbaar in de gosuda/httpwrap repository.