Gestire gli errori HTTP in modo meno fastidioso + RFC7807
Panoramica
Quando si creano http api in Go, la gestione degli errori è la parte più fastidiosa. Tipicamente, si trova codice come questo.
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 il numero di API non è elevato, scrivere in questo modo non comporterà particolari inconvenienti. Tuttavia, man mano che il numero di API aumenta e la logica interna diventa più complessa, tre aspetti diventano fastidiosi.
- Restituzione di codici di errore appropriati
- Elevato numero di log di risultato da scrivere
- Invio di messaggi di errore chiari
Corpo principale
Restituzione di codici di errore appropriati
Certamente, il punto 1, la restituzione di codici di errore appropriati, è una mia lamentela personale. Uno sviluppatore esperto troverà il codice appropriato e lo inserirà correttamente ogni volta, ma sia io che gli sviluppatori ancora inesperti possiamo trovare difficile utilizzare regolarmente codici di errore appropriati man mano che la logica diventa più complessa e le chiamate più numerose. Esistono diversi modi per affrontare questo problema, e il più comune è progettare in anticipo il flusso della logica API e scrivere il codice in modo da restituire l'errore appropriato. Faccia così
Tuttavia, questo non sembra essere il metodo ottimale per uno sviluppatore umano che si avvale dell'aiuto di un IDE (o Language Server). Inoltre, dato che le REST API stesse sfruttano al massimo il significato contenuto nei codici di errore, si può proporre un altro approccio. Si crea una nuova implementazione dell'interfaccia error
chiamata HttpError
, che memorizza StatusCode
e Message
. E si fornisce la seguente funzione helper.
1err := httperror.BadRequest("wrong format")
La funzione helper BadRequest
restituirà un HttpError
con StatusCode
impostato a 400 e Message
impostato sul valore ricevuto come argomento. Oltre a questa, sarà ovviamente possibile cercare e aggiungere funzioni helper come NotImplement
, ServiceUnavailable
, Unauthorized
, PaymentRequired
, ecc. tramite la funzionalità di completamento automatico. Questo sarà più veloce rispetto a controllare ogni volta una specifica progettuale già pronta, e più stabile rispetto a inserire ogni volta il codice di errore numerico. Sono già tutti nelle costanti http.StatusCode
? Shh
Elevato numero di log di risultato da scrivere
Quando si verifica un errore, è naturale lasciare un log. Quando un'API viene chiamata e si lascia un log per indicare se la richiesta è andata a buon fine o meno, lasciare log dall'inizio a tutti i punti di terminazione previsti comporta la scrittura di una grande quantità di codice. Questo può essere gestito centralmente avvolgendo l'handler stesso una volta.
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 Router contiene internamente un chi.Router
, ed è configurata per utilizzare le sue funzionalità. Nel metodo Get
, come proposto poco sopra, viene controllato se è stata restituita la struttura HttpError
restituita dalla funzione helper, e in tal caso viene restituita appropriatamente. Se invece è un error
generico, viene passato in blocco alla funzione di callback per gli errori. Questa callback viene ricevuta 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 "os" // Commento aggiunto: Importare il pacchetto os
11 "os/signal" // Commento aggiunto: Importare il pacchetto os/signal
12 "syscall" // Commento aggiunto: Importare il pacchetto syscall
13
14 "github.com/gosuda/httpwrap/httperror"
15 "github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
20 defer cancel()
21
22 r := chiwrap.NewRouter(func(err error) {
23 log.Printf("Router log test: Error occured: %v", err)
24 })
25 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
26 name := request.URL.Query().Get("name")
27 if name == "" {
28 return httperror.BadRequest("name is required")
29 }
30
31 writer.Write([]byte("Hello " + name))
32 return nil
33 })
34
35 svr := http.Server{
36 Addr: ":8080",
37 Handler: r,
38 }
39 go func() {
40 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
41 log.Fatalf("Failed to start server: %v", err)
42 }
43 }()
44
45 <-ctx.Done()
46 svr.Shutdown(context.Background())
47}
Cosa ne pensa? Semplicemente creando e restituendo un HttpError
tramite una funzione helper, si può rispondere con il codice di errore appropriato e un messaggio dalla scope superiore, e gestire i log specifici per ogni servizio registrando una callback. Se necessario, si può estendere ulteriormente per consentire un logging dettagliato utilizzando, ad esempio, il RequestID
.
Invio di messaggi di errore chiari
Il documento RFC7807 esiste a questo scopo. RFC7807 definisce e utilizza principalmente i seguenti elementi:
type
: URI che identifica il tipo di errore. Solitamente è un documento che descrive l'errore.title
: Descrizione dell'errore in una riga.status
: Identico all'HTTP Status Code.detail
: Descrizione dettagliata dell'errore, leggibile da un essere umano.instance
: URI dove si è verificato l'errore. Ad esempio, se un errore si è verificato suGET /user/info
, il valore sarà/user/info
.extensions
: Elementi secondari per descrivere l'errore, strutturati come JSON Object.- Ad esempio, in caso di
BadRequest
, potrebbe includere l'input dell'utente. - Oppure, in caso di
TooManyRequest
, potrebbe includere l'orario dell'ultima richiesta.
- Ad esempio, in caso di
Per facilitarne l'uso, si crea un nuovo file nella stessa posizione di HttpError
, ovvero nel pacchetto httperror
, si crea la struttura RFC7807Error
e si rende 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", // Commento aggiunto: 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 { /* ... */ return p /* Commento aggiunto: Implementazione omessa per brevità */ } // Commento aggiunto: Aggiunto return p
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { /* ... */ return p /* Commento aggiunto: Implementazione omessa per brevità */ } // Commento aggiunto: Aggiunto return p
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { /* ... */ return p /* Commento aggiunto: Implementazione omessa per brevità */ } // Commento aggiunto: Aggiunto return p
Il valore "about:blank"
per Type
è il valore predefinito. Significa una pagina inesistente. Di seguito un esempio di creazione di un errore per una richiesta errata.
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 scritto in precedenza, si può supportare il seguente metodo.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Commento aggiunto: Se la marshaling fallisce, ricorrere all'uso del solo dettaglio
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Utilizzando questo direttamente, l'esempio precedente viene modificato così.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10 "os" // Commento aggiunto: Importare il pacchetto os
11 "os/signal" // Commento aggiunto: Importare il pacchetto os/signal
12 "syscall" // Commento aggiunto: Importare il pacchetto syscall
13
14 "github.com/gosuda/httpwrap/httperror"
15 "github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
20 defer cancel()
21
22 r := chiwrap.NewRouter(func(err error) {
23 log.Printf("Router log test: Error occured: %v", err)
24 })
25 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
26 name := request.URL.Query().Get("name")
27 if name == "" {
28 return httperror.BadRequestProblem("name is required", "Bad User Input").
29 WithType("https://example.com/errors/validation").
30 WithInstance("/api/echo").
31 WithExtension("invalid_field", "name").
32 WithExtension("expected_format", "string").
33 WithExtension("actual_value", name).
34 ToHttpError()
35 }
36
37 writer.Write([]byte("Hello " + name))
38 return nil
39 })
40
41 svr := http.Server{
42 Addr: ":8080",
43 Handler: r,
44 }
45 go func() {
46 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
47 log.Fatalf("Failed to start server: %v", err)
48 }
49 }()
50
51 <-ctx.Done()
52 svr.Shutdown(context.Background())
53}
Conclusione
Gestendo gli errori in questo modo, utilizzando un router centralizzato, si può ridurre il carico di lavoro legato alla verifica dei codici di errore e alla scrittura di messaggi di errore appropriati ogni volta. Inoltre, utilizzando RFC7807 per fornire messaggi di errore strutturati, si può aiutare il client a comprendere e gestire gli errori. Attraverso questi metodi, la gestione degli errori nelle HTTP API scritte in Go può essere resa più semplice e coerente.
Il codice di questo articolo è disponibile nel repository gosuda/httpwrap.