GoSuda

Tratarea mai puțin deranjantă a erorilor HTTP + RFC7807

By snowmerak
views ...

Prezentare generală

Atunci când se creează API-uri http în limbajul Go, cel mai supărător 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 fi deosebit de incomodă. Totuși, pe măsură ce numărul de API-uri crește și logica internă devine mai complexă, trei aspecte devin deranjante.

  1. Returnarea codului de eroare adecvat
  2. Numărul mare de linii de log pentru rezultate
  3. Transmiterea mesajului de eroare clar

Cuprins principal

Returnarea codului de eroare adecvat

Desigur, punctul 1, returnarea codului de eroare adecvat, este o nemulțumire personală. Un dezvoltator experimentat ar găsi codul adecvat și l-ar insera corect de fiecare dată, dar atât eu, cât și dezvoltatorii încă neexperimentați, putem întâmpina dificultăți în utilizarea regulată a codurilor de eroare adecvate pe măsură ce logica devine mai complexă și numărul de apeluri crește. Există diverse metode pentru a aborda acest aspect, iar cea mai reprezentativă ar fi proiectarea prealabilă a fluxului logic al API-ului și apoi scrierea codului pentru a returna eroarea adecvată. Procedați astfel

Însă, aceasta nu pare a fi metoda optimă pentru un dezvoltator uman care beneficiază de ajutorul unui IDE (sau al unui Language Server). De asemenea, având în vedere că REST API-ul în sine utilizează la maximum semnificația codurilor de eroare, se poate propune o altă abordare. Se creează o nouă implementare a interfeței de eroare (error) numită HttpError, care să stocheze StatusCode și Message. Și se oferă următoarele funcții ajutătoare:

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

Funcția ajutătoare BadRequest va returna un HttpError cu StatusCode setat la 400 și Message setat la valoarea primită ca argument. Pe lângă aceasta, desigur, vor putea fi căutate și adăugate funcții ajutătoare precum NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired etc. prin funcția de autocompletare. Aceasta este mai rapid decât verificarea planului de proiectare pregătit de fiecare dată și mai stabil decât introducerea manuală a codului de eroare numeric de fiecare dată. Codurile http.StatusCode conțin deja toate acestea? Shhh

Numărul mare de linii de log pentru rezultate

În cazul apariției unei erori, se înregistrează, desigur, loguri. 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 finalizare anticipate crește numărul de linii de cod de scris. Aceasta poate fi gestionată centralizat prin încapsularea handler-ului în sine.

Următorul este 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 conține intern chi.Router, fiind configurată să utilizeze funcționalitățile chi.Router ca atare. În metoda Get, se verifică dacă a fost returnată structura HttpError returnată de funcția ajutătoare propusă mai sus, 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 constructor.

Următorul este 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? Pur și simplu returnând HttpError creat cu o funcție ajutătoare, la un nivel superior se poate returna un răspuns cu codul de eroare și mesajul adecvat, iar prin înregistrarea unui callback, se pot înregistra loguri adecvate pentru fiecare serviciu implementat. Dacă este necesar, se poate extinde pentru a permite logarea detaliată utilizând RequestID etc.

Transmiterea mesajului de eroare clar

Documentul pentru acest aspect este 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 scurtă descriere a tipului de eroare.
  • status: Identic cu HTTP Status Code.
  • detail: O descriere detaliată a erorii, lizibilă pentru om.
  • instance: URI-ul unde a apărut eroarea. De exemplu, dacă a apărut o eroare la GET /user/info, valoarea va fi /user/info.
  • extensions: Elemente secundare pentru descrierea erorii, structurate ca un JSON Object.
    • De exemplu, în cazul unui BadRequest, poate include inputul utilizatorului.
    • Sau în cazul TooManyRequest, poate include momentul celei mai recente cereri.

Pentru a facilita utilizarea acestora, se creează un nou fișier în pachetul httperror, în aceeași locație cu HttpError, și se creează structura RFC7807Error, permițând crearea prin modelul de chain 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 { ... }

"about:blank" pentru Type este valoarea implicită. Înseamnă 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")

Cu un simplu chain de metode, se poate crea 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}

Folosind aceasta ca atare, 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 codului de eroare de fiecare dată și a scrierii mesajului de eroare adecvat. De asemenea, prin utilizarea RFC7807 pentru a oferi mesaje de eroare structurate, se poate ajuta clientul să înțeleagă și să gestioneze erorile. Prin aceste metode, gestionarea erorilor în API-urile HTTP scrise în limbajul Go poate deveni mai simplă și mai consecventă.

Codul din acest articol poate fi găsit în depozitul gosuda/httpwrap.