GoSuda

Håndtering av HTTP-feil med mindre omstendelighet + RFC7807

By snowmerak
views ...

Oversikt

Når man konstruerer en http api i Go-språket, er det mest plagsomme feilhåndteringen. Et representativt eksempel på slik kode er følgende:

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}

Dersom antallet APIer er begrenset, vil det neppe medføre nevneverdige ulemper å strukturere koden på denne måten. Imidlertid, etter hvert som antallet APIer øker og den interne logikken blir mer kompleks, vil følgende tre aspekter fremstå som stadig mer påtrengende:

  1. Tilbakelevering av adekvate feilkoder
  2. Det store antallet loggføringer som kreves for resultater
  3. Formidling av tydelige feilmeldinger

Hoveddel

Tilbakelevering av adekvate feilkoder

Selvsagt er punkt 1, tilbakelevering av adekvate feilkoder, et subjektivt anliggende for undertegnede. Erfarne utviklere vil utvilsomt finne og implementere de korrekte kodene konsekvent, men mindre erfarne utviklere, slik som jeg selv, kan oppleve vanskeligheter med å anvende egnede feilkoder regelmessig ettersom logikken blir mer kompleks og forekomstene hyppigere. Det finnes flere tilnærminger til dette, og den mest representative er sannsynligvis å forhåndsdesigne API-logikkflyten for deretter å implementere koden for å returnere de relevante feilene. Gjør nettopp det

Imidlertid fremstår ikke dette som den optimale metoden for en menneskelig utvikler som assisteres av et IDE (eller en Language Server). Gitt at REST APIer i seg selv utnytter meningen i feilkodene i størst mulig grad, kan en alternativ tilnærming foreslås. Vi kan opprette en ny implementasjon av error-grensesnittet kalt HttpError, som lagrer StatusCode og Message. Deretter tilbys følgende hjelpefunksjon:

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

BadRequest-hjelpefunksjonen vil returnere en HttpError der StatusCode er satt til 400 og Message er satt til verdien fra argumentet. Det vil naturligvis også være mulig å søke opp og legge til hjelpefunksjoner som NotImplement, ServiceUnavailable, Unauthorized, og PaymentRequired ved hjelp av autokomplettering. Dette er raskere enn å konsultere et forberedt designskjema hver gang, og mer robust enn å inntaste feilkoder som numeriske verdier hver gang. De finnes da i http.StatusCode-konstantene? Stille nå

Det store antallet loggføringer for resultater

Ved feiltilfeller er det naturlig å etterlate logger. Å logge ved startpunktet og alle forventede sluttpunkter når man sporer API-kall, enten de lykkes eller feiler, øker mengden kode som må skrives. Dette kan sentraliseres ved å omslutte selve handleren.

Følgende er et eksempel på innpakning av en 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}

Router-strukturen er konfigurert til å benytte funksjonaliteten til chi.Router ettersom den inneholder en intern chi.Router. Hvis man observerer Get-metoden, vil man se at den sjekker om HttpError-strukturen, returnert av hjelpefunksjonen nettopp foreslått ovenfor, er mottatt, og returnerer deretter passende respons; dersom det foreligger en generell error, videresendes den samlet til feil-callback-funksjonen. Denne callbacken mottas via konstruktøren.

Følgende viser koden implementert ved å benytte 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}

Formidling av tydelige feilmeldinger

Dokumentet som adresserer dette er RFC7807. RFC7807 definerer og benytter hovedsakelig følgende komponenter:

  • type: En URI som identifiserer feiltypen. Dette er primært et dokument som beskriver feilen.
  • title: En kortfattet beskrivelse av hva feilen er.
  • status: Tilsvarende HTTP Status Code.
  • detail: En detaljert, menneskelesbar beskrivelse av den aktuelle feilen.
  • instance: URIen der feilen oppstod. For eksempel, dersom feilen oppstod ved GET /user/info, vil /user/info være verdien.
  • extensions: Sekundære elementer strukturert som et JSON Object for å beskrive feilen.
    • For eksempel, ved BadRequest kan brukerinput inkluderes.
    • Alternativt, ved TooManyRequest, kan tidspunktet for den seneste forespørselen inkluderes.

For å lette bruken av dette, kan man opprette en ny fil i httperror-pakken, på samme nivå som HttpError, opprette en RFC7807Error-struktur, og muliggjøre konstruksjon via et metodekjedningsmønster.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Standard type 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" for Type er standardverdien. Dette indikerer en ikke-eksisterende side. Nedenfor følger et eksempel på feilgenerering for en 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")

Ved hjelp av enkel metodekjedning kan man generere en strukturert feilmelding rettet mot brukeren. Videre, for å kunne utnytte den sentraliserte ruteren som ble definert tidligere, kan følgende metode støttes:

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Dersom marshaling feiler, fall tilbake til kun å benytte detaljen
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Ved å benytte dette direkte, modifiseres eksempelet ovenfor til å se slik ut:

 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 å anvende en sentralisert ruter for feilhåndtering som vist her, kan byrden ved å måtte verifisere feilkoder og utforme egnede feilmeldinger gjentatte ganger reduseres. Videre, ved å benytte RFC7807 for å levere strukturerte feilmeldinger, kan dette assistere klienten med å forstå og håndtere feilene. Gjennom slike metoder kan feilhåndteringen i HTTP APIer skrevet i Go-språket gjøres både mer uanstrengt og konsistent.

Koden for denne artikkelen kan finnes i gosuda/httpwrap-repositoriet.