Gestire gli errori HTTP in modo meno gravoso + RFC7807

Panoramica
Quando si creano API HTTP in Go, la parte più fastidiosa è la gestione degli errori. Un esempio tipico è questo codice.
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}
Se le API sono poche, scrivere il codice in questo modo non dovrebbe creare particolari problemi. Tuttavia, man mano che il numero delle API aumenta e la logica interna diventa più complessa, tre aspetti iniziano a risultare fastidiosi.
- Restituzione di codici di errore appropriati
- Numero eccessivo di scritture di log
- Invio di messaggi di errore chiari
Discussione principale
Restituzione di codici di errore appropriati
Il punto 1, la restituzione di codici di errore appropriati, è certamente una mia lamentela personale. Uno sviluppatore esperto troverà sempre il codice adatto e lo inserirà correttamente ogni volta, ma io stesso e gli sviluppatori ancora inesperti possono avere difficoltà a usare in modo coerente i codici di errore corretti man mano che la logica diventa complessa e il numero di casi aumenta. Esistono diversi approcci a questo problema; il più comune è progettare in anticipo il flusso logico dell'API e scrivere il codice in modo che restituisca l'errore appropriato. Fatelo pure
Tuttavia, questo non sembra l'approccio ottimale per uno sviluppatore umano che si avvale dell'IDE (o del Language Server). Inoltre, poiché le REST API stesse sfruttano al massimo il significato dei codici di errore, si può proporre un altro metodo. Creiamo un nuovo tipo che implementa l'interfaccia error, chiamato HttpError, che memorizza StatusCode e Message. Poi forniamo funzioni helper come la seguente.
1err := httperror.BadRequest("wrong format")
La funzione helper BadRequest restituirà un HttpError con StatusCode impostato a 400 e Message impostato al valore ricevuto come argomento. Oltre a questa, si possono naturalmente aggiungere e consultare, tramite il completamento automatico, funzioni helper come NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired e altre. Questo approccio è più rapido che consultare ogni volta un progetto predefinito, e più affidabile che inserire manualmente i codici numerici. Dicono che sia tutto nella costante http.StatusCode? Shh
Numero eccessivo di scritture di log
In caso di errore, naturalmente si scrive un log. Quando si registra se una chiamata API è riuscita o fallita, scrivere un log in ogni possibile punto di uscita, fin dall'inizio, aumenta il numero di righe di codice. Avvolgendo l'handler stesso, questa gestione può essere centralizzata.
Di seguito un esempio che avvolge il router chi.
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}
La struttura del router contiene al suo interno chi.Router, quindi è configurata in modo da poter utilizzare direttamente le funzionalità di chi.Router. Osservando il metodo Get, si verifica se è stata restituita una struttura HttpError, come quella prodotta dalla funzione helper proposta poco fa, e la si restituisce in modo appropriato; nel caso di un error generico, questo viene inoltrato in modo uniforme alla funzione di callback per gli errori. Questo callback viene ricevuto tramite il costruttore.
Di seguito il codice scritto utilizzando questo pacchetto.
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}
Che ne pensate? Se si crea semplicemente un HttpError tramite una funzione helper e lo si restituisce, il livello superiore può rispondere con il codice di errore e il messaggio appropriati, mentre si può registrare un callback che scrive il log adatto a ciascun servizio. Se necessario, si può ampliare il sistema, usando ad esempio RequestID, per ottenere una registrazione dettagliata.
Invio di messaggi di errore chiari
A tal fine esiste il documento RFC7807. RFC7807 definisce e utilizza principalmente i seguenti elementi.
type: URI che identifica il tipo di errore. Di solito è un documento che descrive l'errore.title: una breve descrizione del tipo di errore.status: uguale al codice di stato HTTP.detail: una descrizione dettagliata dell'errore, leggibile da una persona.instance: l'URI in cui si è verificato l'errore. Ad esempio, se l'errore si è verificato inGET /user/info, il valore sarà/user/info.extensions: elemento accessorio per descrivere l'errore, costituito da un oggetto JSON.- Ad esempio, in caso di
BadRequest, possono essere inclusi gli input dell'utente. - Oppure, in caso di
TooManyRequest, si può includere il momento della richiesta più recente.
- Ad esempio, in caso di
Per usarlo facilmente, creiamo un nuovo file nel pacchetto httperror, nella stessa posizione di HttpError, definiamo la struttura RFC7807Error e rendiamo possibile la sua creazione tramite il pattern di method chaining.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Tipo predefinito secondo 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 { ... }
Il valore "about:blank" di Type è quello predefinito. Indica una pagina inesistente. Di seguito un esempio di creazione di un errore per una richiesta non valida.
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")
Con un semplice method chaining è possibile creare un messaggio di errore strutturato per l'utente. Inoltre, per utilizzare il router centralizzato presentato in precedenza, si può supportare il seguente metodo.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Se la serializzazione fallisce, usa semplicemente il detail
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Usando questo metodo così com'è, modifichiamo l'esempio precedente come segue.
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}
Conclusione
Gestire gli errori tramite un router centralizzato riduce il carico di dover verificare ogni volta i codici di errore e scrivere messaggi appropriati. Inoltre, utilizzando RFC7807 per fornire messaggi di errore strutturati, si aiuta il client a comprendere e gestire l'errore. Con questi metodi, la gestione degli errori nelle API HTTP scritte in Go può diventare più semplice e coerente.
Il codice di questo articolo è disponibile nel repository gosuda/httpwrap.