GoSuda

Håndtering af HTTP-fejl med færre besværligheder + RFC7807

By snowmerak
views ...

Oversigt

Når man opretter http API'er i Go-sproget, er fejlhåndtering det mest besværlige. Typisk findes der kode som denne:

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 ikke er mange API'er, vil denne måde at skrive det på sandsynligvis ikke være ubelejlig. Men efterhånden som antallet af API'er stiger, og den interne logik bliver mere kompleks, bliver tre ting irriterende:

  1. Returnering af passende fejlkoder
  2. Højt antal logskrivninger af resultater
  3. Afsendelse af klare fejlmeddelelser

Hoveddel

Returnering af passende fejlkoder

Selvfølgelig er punkt 1, returnering af passende fejlkoder, en personlig klage fra min side. En erfaren udvikler ville hver gang finde og indsætte den passende kode, men både jeg og uerfarne udviklere kan have svært ved at bruge passende fejlkoder regelmæssigt, efterhånden som logikken bliver mere kompleks og antallet af gentagelser stiger. Der findes flere metoder til dette, og den mest typiske er sandsynligvis at designe API-logikkens flow på forhånd og derefter skrive kode, der returnerer den passende fejl. Gør det

Men dette virker ikke som den optimale metode for menneskelige udviklere, der får hjælp fra en IDE (eller Language Server). Desuden, da REST API'et i sig selv maksimerer brugen af betydningen indeholdt i fejlkoder, kan en anden tilgang foreslås. Opret en ny error interface-implementering kaldet HttpError, der gemmer StatusCode og Message. Og leverer følgende hjælpefunktion:

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

BadRequest hjælpefunktionen vil returnere en HttpError med StatusCode sat til 400 og Message sat til den værdi, der blev modtaget som argument. Udover dette vil det selvfølgelig være muligt at forespørge og tilføje hjælpefunktioner som NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired osv. via autofuldførelse. Dette vil være hurtigere end at kontrollere det forberedte designspecifikation hver gang, og mere stabilt end at indtaste fejlkoden som et tal hver gang. http.StatusCode konstanter indeholder det hele? Shhh

Højt antal logskrivninger af resultater

Når en fejl opstår, logges den naturligvis. Når et API kaldes, og der logges, om anmodningen lykkedes eller mislykkedes, vil det at logge fra starten til alle forventede slutpunkter resultere i et stort antal kodelinjer. Dette kan håndteres centralt ved at indkapsle selve håndtereren.

Følgende er et eksempel på indkapsling af 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}

Router-strukturen indeholder en chi.Router internt, så den er konfigureret til at bruge chi.Routers funktionalitet direkte. Hvis man ser på Get-metoden, kontrollerer den, om HttpError-strukturen, der returneres af den hjælpefunktion, vi lige har foreslået, er returneret, og returnerer den derefter passende. Hvis det er en error, videresendes den samlet til fejl-callback-funktionen. Denne callback modtages via konstruktøren.

Følgende er kode skrevet ved hjælp af 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? Ved blot at oprette og returnere HttpError som en hjælpefunktion, kan du registrere en callback til at håndtere logning, så den overordnede scope kan returnere et passende fejlcode og en besked, og hver implementeret service kan logge passende. Derudover, hvis det er nødvendigt, kan det udvides til at muliggøre detaljeret logning ved hjælp af RequestID osv.

Afsendelse af klare fejlmeddelelser

Dokumentet til dette er RFC7807. RFC7807 definerer og bruger hovedsageligt følgende elementer:

  • type: En URI, der identificerer fejltypen. Det er primært et dokument, der beskriver fejlen.
  • title: En enlinjes beskrivelse af, hvilken fejl det er.
  • status: Identisk med HTTP Status Code.
  • detail: En menneskelæselig detaljeret beskrivelse af fejlen.
  • instance: URI'en, hvor fejlen opstod. Hvis fejlen f.eks. opstod ved GET /user/info, vil /user/info være værdien.
  • extensions: Yderligere elementer til at beskrive fejlen, struktureret som et JSON Object.
    • For eksempel, i tilfælde af BadRequest, kan brugerens input inkluderes.
    • Eller i tilfælde af TooManyRequest, kan det seneste anmodningstidspunkt inkluderes.

For at gøre dette nemt at bruge, oprettes en ny fil i httperror-pakken, samme sted som HttpError, og en RFC7807Error-struktur oprettes, som kan genereres ved hjælp af et metodekædemønster.

 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 { ... }

Typeens "about:blank" er standardværdien. Det betyder en side, der ikke findes. Nedenfor er et eksempel på fejlgenerering for 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")

En struktureret fejlmeddelelse til brugeren kan oprettes med en simpel metodekæde. Desuden kan følgende metode understøttes for at bruge den centraliserede router, der er skrevet ovenfor:

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}

Ved at bruge dette som det er, ændres ovenstående eksempel til dette:

 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

Ved at bruge en centraliseret router som denne til fejlhåndtering kan man reducere byrden ved hver gang at kontrollere fejlkoder og skrive passende fejlmeddelelser. Desuden kan strukturerede fejlmeddelelser, der leveres ved hjælp af RFC7807, hjælpe klienter med at forstå og håndtere fejl. Gennem denne metode kan fejlhåndteringen af HTTP API'er skrevet i Go-sproget gøres mere enkel og konsekvent.

Koden for denne artikel kan findes i gosuda/httpwrap repository.