GoSuda

Gestionarea mai puțin anevoioasă a erorilor HTTP + RFC7807

By snowmerak views ...

Prezentare generală

Atunci când creați un API HTTP în limbajul Go, cel mai supărător lucru este gestionarea erorilor. Un exemplu tipic este următorul cod.

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}

Dacă numărul de API-uri este redus, scrierea în acest mod nu va fi, în mod real, incomodă. Totuși, pe măsură ce numărul API-urilor crește și logica internă devine mai complexă, trei aspecte încep să devină problematice.

  1. Returnarea unui cod de eroare adecvat
  2. Numărul mare de scrieri de jurnal (log) asociate rezultatelor
  3. Transmiterea unui mesaj de eroare clar

Dezvoltare

Returnarea unui cod de eroare adecvat

Desigur, primul punct, returnarea unui cod de eroare adecvat, reprezintă o nemulțumire personală. Un dezvoltator experimentat va găsi codul potrivit și îl va introduce corect de fiecare dată, însă eu, și la fel dezvoltatorii încă neexperimentați, pot întâmpina dificultăți în utilizarea regulată a codurilor de eroare potrivite, pe măsură ce logica devine mai complexă și numărul apelurilor crește. Pentru aceasta există mai multe metode, iar cea mai reprezentativă ar fi proiectarea fluxului logic al API-ului în prealabil și apoi scrierea codului care returnează erorile corespunzătoare. Faceți așa

Totuși, această abordare nu pare optimă pentru un dezvoltator uman care beneficiază de ajutorul IDE-ului (sau al Language Server-ului). De asemenea, deoarece REST API utilizează în mod intrinsec semnificația codurilor de eroare în măsură maximă, se poate propune o altă metodă. Se creează o implementare nouă a interfeței de eroare (error), numită HttpError, care stochează StatusCode și Message. Apoi se oferă funcții auxiliare de tip helper, precum cele de mai jos.

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

Funcția helper BadRequest va returna un HttpError în care StatusCode este setat la 400, iar Message la valoarea primită ca argument. Pe lângă acestea, se pot adăuga și consulta, desigur, funcții helper precum NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired prin funcția de completare automată. Aceasta este mai rapidă decât consultarea repetată a unui proiect de design pregătit și mai sigură decât introducerea manuală, de fiecare dată, a codurilor de eroare sub formă numerică. Toate sunt în constantele http.StatusCode? Șșt

Numărul mare de scrieri de jurnal (log) asociate rezultatelor

La apariția unei erori, este firesc să se lase un jurnal. Atunci când se înregistrează dacă un apel API a reușit sau a eșuat, scrierea unui jurnal la fiecare punct de ieșire previzibil, începând de la început, duce la creșterea numărului de linii de cod. Prin învelirea întregului handler, această gestionare poate fi centralizată.

Urmează un exemplu de învelire a routerului 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 structura routerului, chi.Router este păstrat în interior, astfel încât funcționalitățile lui chi.Router sunt utilizate exact în forma lor originală. Analizând metoda Get, se verifică dacă a fost returnată structura HttpError generată de funcțiile helper propuse mai sus, și apoi se răspunde în mod corespunzător; în cazul unui error obișnuit, acesta este transmis uniform către funcția de callback pentru erori. Acest callback este primit prin constructor.

Urmează codul scris folosind acest pachet.

 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}

Ce părere aveți? Dacă se creează doar HttpError ca funcție helper și se returnează, nivelul superior poate răspunde cu codul de eroare și mesajul potrivite, iar prin înregistrarea unui callback, fiecare serviciu implementat poate scrie jurnalul corespunzător. Suplimentar, dacă este necesar, se poate extinde sistemul și folosi RequestID și alte elemente pentru o înregistrare detaliată a jurnalelor.

Transmiterea unui mesaj de eroare clar

Pentru aceasta există documentul RFC7807. RFC7807 definește și utilizează, în principal, următoarele elemente.

  • type: URI care identifică tipul erorii. De regulă, este un document care descrie eroarea.
  • title: O descriere scurtă, pe o singură linie, a erorii.
  • status: Identic cu codul de stare HTTP.
  • detail: O descriere detaliată a erorii, lizibilă pentru om.
  • instance: URI-ul la care a apărut eroarea. De exemplu, dacă eroarea a apărut la GET /user/info, valoarea va fi /user/info.
  • extensions: Element secundar, structurat ca obiect JSON, destinat descrierii erorii.
    • De exemplu, în cazul BadRequest, pot fi incluse datele introduse de utilizator.
    • Sau, în cazul TooManyRequest, poate fi inclus momentul cel mai recent al cererii.

Pentru a facilita utilizarea, în pachetul httperror, în același loc cu HttpError, se creează un fișier nou, se definește structura RFC7807Error și se permite crearea acesteia prin modelul de înlănțuire a metodelor (method chaining).

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

Valoarea "about:blank" a câmpului Type este cea implicită. Ea semnifică o pagină inexistentă. Mai jos este un exemplu de creare a unei erori pentru o cerere incorectă.

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

Prin utilizarea simplă a metodelor înlănțuite, se poate genera un mesaj de eroare structurat pentru utilizator. De asemenea, pentru a utiliza routerul centralizat creat anterior, se poate oferi suport prin următoarea metodă.

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}

Folosind direct această metodă și modificând exemplul de mai sus, se obține următorul rezultat.

 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}

Concluzie

Prin gestionarea erorilor cu ajutorul unui router centralizat, povara verificării codurilor de eroare și a redactării unor mesaje de eroare adecvate de fiecare dată este redusă. De asemenea, prin utilizarea RFC7807 pentru a furniza mesaje de eroare structurate, se poate sprijini clientul în înțelegerea și tratarea erorilor. Prin aceste metode, gestionarea erorilor în cadrul API-urilor HTTP scrise în limbajul Go poate deveni mai simplă și mai coerentă.

Codul din acest articol poate fi consultat în repository-ul gosuda/httpwrap.