GoSuda

Gestionarea erorilor HTTP cu mai puțină bătaie de cap + RFC7807

By snowmerak
views ...

Prezentare generală

Atunci când se creează o interfață HTTP API în limbajul Go, cel mai anevoios aspect 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 nu este mare, scrierea codului în acest mod nu va genera inconveniente semnificative. Cu toate acestea, pe măsură ce numărul de API-uri crește și logica internă devine mai complexă, trei aspecte devin deranjante:

  1. Returnarea unui cod de eroare adecvat
  2. Numărul mare de înregistrări de loguri rezultate
  3. Transmiterea unui mesaj de eroare clar

Corpul textului

Returnarea unui cod de eroare adecvat

Desigur, punctul 1, returnarea unui cod de eroare adecvat, este o nemulțumire personală. Un dezvoltator experimentat ar identifica și introduce codul corect de fiecare dată, dar, atât eu, cât și dezvoltatorii mai puțin experimentați, putem întâmpina dificultăți în utilizarea consecventă a codurilor de eroare adecvate pe măsură ce logica devine mai complexă și numărul de operații crește. Există diverse abordări în acest sens, iar cea mai reprezentativă ar fi probabil proiectarea prealabilă a fluxului logic al API-ului și scrierea codului pentru a returna erori adecvate. Faceți asta.

Totuși, aceasta nu pare a fi cea mai optimă metodă pentru dezvoltatorii umani care beneficiază de asistența unui IDE (sau Language Server). De asemenea, având în vedere că REST API-urile utilizează la maximum semnificația codurilor de eroare, se poate propune o altă abordare. Se creează o nouă implementare a interfeței error denumită HttpError, care stochează StatusCode și Message. Apoi, se oferă următoarea funcție auxiliară:

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

Funcția auxiliară BadRequest va returna un HttpError cu StatusCode setat la 400 și Message setat la valoarea primită ca argument. Pe lângă aceasta, vor putea fi căutate și adăugate, prin intermediul funcției de autocompletare, și alte funcții auxiliare precum NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired. Această metodă este mai rapidă decât verificarea constantă a documentației de proiectare și mai stabilă decât introducerea manuală a codurilor numerice de eroare de fiecare dată. http.StatusCode conține deja toate constantele? Sssst!

Numărul mare de înregistrări de loguri rezultate

În mod natural, atunci când apare o eroare, se înregistrează un log. Atunci când un API este apelat și se înregistrează loguri despre succesul sau eșecul cererii, înregistrarea logurilor de la început până la toate punctele de terminare anticipate crește numărul de linii de cod de scris. Acest lucru poate fi gestionat centralizat, prin încapsularea handler-ului în sine.

Urmează un exemplu de încapsulare a ruterului 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}

Structura Router încapsulează un chi.Router intern, permițând utilizarea directă a funcționalităților chi.Router. În metoda Get, se verifică dacă a fost returnată o structură HttpError de către funcția auxiliară menționată anterior, se returnează corespunzător, iar în cazul unei error, aceasta este transmisă în mod uniform funcției de callback pentru erori. Acest callback este primit prin intermediul constructorului.

Urmează codul scris utilizând 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? Prin simpla returnare a unui HttpError printr-o funcție auxiliară, se poate răspunde cu un cod de eroare și un mesaj adecvate în scopul superior, în timp ce se înregistrează un callback pentru a permite înregistrarea logurilor adecvate pentru fiecare serviciu implementat. În plus, dacă este necesar, se poate extinde funcționalitatea pentru a permite logarea detaliată, utilizând, de exemplu, RequestID.

Transmiterea unui mesaj de eroare clar

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

  • type: Un URI care identifică tipul de eroare. De obicei, este un document care descrie eroarea.
  • title: O descriere scurtă a naturii erorii.
  • status: Identic cu codul de stare HTTP.
  • detail: O descriere detaliată a erorii, inteligibilă pentru om.
  • instance: URI-ul unde a apărut eroarea. De exemplu, dacă o eroare a apărut la GET /user/info, valoarea ar fi /user/info.
  • extensions: Elemente secundare, structurate ca un obiect JSON, pentru a descrie eroarea.
    • De exemplu, în cazul unui BadRequest, ar putea include intrarea utilizatorului.
    • Sau, în cazul unui TooManyRequest, ar putea include momentul celei mai recente cereri.

Pentru a facilita utilizarea acestuia, se creează un fișier nou în pachetul httperror, în aceeași locație cu HttpError, și se generează o structură RFC7807Error, care poate fi creată printr-un pattern de înlănțuire de metode.

 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" pentru Type este implicită. Aceasta indică 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 înlănțuirea simplă a metodelor, se poate genera un mesaj de eroare structurat pentru utilizator. De asemenea, pentru a utiliza ruterul centralizat scris anterior, se poate suporta 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}

Utilizând acest lucru direct, exemplul de mai sus se modifică astfel:

 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 utilizarea unui ruter centralizat pentru gestionarea erorilor, se poate reduce povara verificării constante a codurilor de eroare și a redactării mesajelor de eroare adecvate. De asemenea, prin utilizarea RFC7807 pentru a oferi mesaje de eroare structurate, se poate ajuta clientul să înțeleagă și să gestioneze erorile. Această metodă poate face gestionarea erorilor în HTTP API-urile scrise în limbajul Go mai simplă și mai consistentă.

Codul aferent acestui articol poate fi găsit în depozitul gosuda/httpwrap.