GoSuda

Gestion plus aisée des erreurs HTTP + RFC7807

By snowmerak
views ...

Aperçu

Lors de la création d'API HTTP dans le langage Go, la gestion des erreurs est l'aspect le plus fastidieux. Par exemple, on trouve ce type de code :

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}

Si le nombre d'API n'est pas élevé, cette approche ne pose pas de problème particulier. Cependant, à mesure que le nombre d'API augmente et que la logique interne se complexifie, trois points deviennent gênants.

  1. Le renvoi d'un code d'erreur approprié
  2. La quantité importante de journaux de résultats à écrire
  3. L'envoi d'un message d'erreur clair

Corps du texte

Renvoi d'un code d'erreur approprié

Certes, le premier point, le renvoi d'un code d'erreur approprié, est une doléance personnelle. Un développeur expérimenté saura trouver et insérer le code approprié à chaque fois, mais les développeurs moins aguerris, y compris moi-même, peuvent rencontrer des difficultés à utiliser des codes d'erreur pertinents de manière cohérente à mesure que la logique se complexifie et que les appels se multiplient. Il existe plusieurs approches à cet égard, la plus courante étant de concevoir au préalable le flux logique de l'API, puis d'écrire le code pour renvoyer l'erreur appropriée.Faites ainsi

Néanmoins, cela ne semble pas être la méthode optimale pour un développeur humain bénéficiant de l'assistance d'un IDE (ou d'un Language Server). De plus, dans la mesure où les API REST elles-mêmes exploitent au maximum la signification contenue dans les codes d'erreur, il est possible de proposer une autre approche. Créons une nouvelle implémentation de l'interface d'erreur (error) nommée HttpError, permettant de stocker StatusCode et Message. Et fournissons la fonction d'assistance suivante :

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

La fonction d'assistance BadRequest renverra un HttpError configuré avec StatusCode à 400 et Message avec la valeur reçue en argument. En plus de cela, il sera naturellement possible de rechercher et d'ajouter des fonctions d'assistance telles que NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired, etc., grâce à la fonction de complétion automatique. Ceci sera plus rapide que de vérifier le document de conception préparé à chaque fois, et plus stable que de saisir le code d'erreur numériquement à chaque reprise.Ils sont tous dans les constantes http.StatusCode ? Chut

Quantité importante de journaux de résultats à écrire

Lorsqu'une erreur survient, des journaux sont naturellement enregistrés. Lorsqu'une API est appelée et que des journaux sont enregistrés pour indiquer si la requête a réussi ou échoué, enregistrer des journaux depuis le début jusqu'à tous les points de fin attendus augmente la quantité de code à écrire. En enveloppant le gestionnaire lui-même, cela permet une gestion centralisée.

Voici un exemple enveloppant un routeur 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 structure du routeur contient chi.Router en interne, elle est donc configurée pour utiliser les fonctionnalités de chi.Router telles quelles. En examinant la méthode Get, vous constaterez qu'elle vérifie si la structure HttpError renvoyée par la fonction d'assistance que nous venons de proposer a été retournée ; elle la renvoie alors de manière appropriée. Dans le cas d'une error générique, elle la transmet systématiquement à la fonction de rappel d'erreur. Cette fonction de rappel est reçue en entrée via le constructeur.

Voici le code écrit en utilisant ce paquetage.

 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		// Test de journal du routeur : Une erreur est survenue : %v
21		log.Printf("Router log test: Error occured: %v", err)
22	})
23	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
24		name := request.URL.Query().Get("name")
25		if name == "" {
26			return httperror.BadRequest("name is required")
27		}
28
29		writer.Write([]byte("Hello " + name))
30		return nil
31	})
32
33	svr := http.Server{
34		Addr:    ":8080",
35		Handler: r,
36	}
37	go func() {
38		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
39			log.Fatalf("Failed to start server: %v", err)
40		}
41	}()
42
43    <-ctx.Done()
44    svr.Shutdown(context.Background())
45}

Qu'en pensez-vous ? En renvoyant simplement un HttpError créé par une fonction d'assistance, il est possible, dans la portée supérieure, de renvoyer la réponse avec le code d'erreur et le message appropriés. Ceci permet également de gérer l'enregistrement de journaux pertinents pour chaque service d'implémentation en enregistrant une fonction de rappel. Si nécessaire, cela peut être étendue pour permettre une journalisation détaillée en utilisant des éléments tels que RequestID.

Transmission d'un message d'erreur clair

Le document RFC7807 existe à cet effet. Le RFC7807 définit et utilise principalement les éléments suivants :

1- `type` : URI identifiant le type d'erreur. Il s'agit généralement d'un document décrivant l'erreur.
2- `title` : Une description en une ligne de l'erreur.
3- `status` : Identique au HTTP Status Code.
4- `detail` : Une description détaillée de l'erreur, lisible par un être humain.
5- `instance` : L'URI où l'erreur s'est produite. Par exemple, si une erreur s'est produite lors de `GET /user/info`, la valeur sera `/user/info`.
6- `extensions` : Éléments secondaires pour décrire l'erreur, structurés sous forme de JSON Object.
7  - Par exemple, dans le cas d'un `BadRequest`, l'entrée de l'utilisateur peut être incluse.
8  - Ou dans le cas d'un `TooManyRequest`, le moment de la requête la plus récente peut être inclus.

Pour faciliter son utilisation, créons un nouveau fichier dans le paquetage httperror, au même endroit que HttpError, créons-y la structure RFC7807Error, et permettons sa création selon un modèle de chaînage de méthodes.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Type par défaut selon 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 { ... }

La valeur "about:blank" pour Type est la valeur par défaut. Elle signifie une page inexistante. Voici un exemple de création d'erreur pour une requête incorrecte.

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

Un simple chaînage de méthodes permet de générer des messages d'erreur structurés pour l'utilisateur. De plus, pour utiliser le routeur centralisé que nous avons écrit précédemment, la méthode suivante peut être prise en charge.

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Si le marshaling échoue, revenir à l'utilisation du détail
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

En l'utilisant tel quel, l'exemple ci-dessus est modifié comme suit.

 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		// Test de journal du routeur : Une erreur est survenue : %v
21		log.Printf("Router log test: Error occured: %v", err)
22	})
23	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
24		name := request.URL.Query().Get("name")
25		if name == "" {
26			return httperror.BadRequestProblem("name is required", "Bad User Input").
27                WithType("https://example.com/errors/validation").
28                WithInstance("/api/echo").
29                WithExtension("invalid_field", "name").
30                WithExtension("expected_format", "string").
31                WithExtension("actual_value", name).
32                ToHttpError()
33		}
34
35		writer.Write([]byte("Hello " + name))
36		return nil
37	})
38
39	svr := http.Server{
40		Addr:    ":8080",
41		Handler: r,
42	}
43	go func() {
44		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
45			log.Fatalf("Failed to start server: %v", err)
46		}
47	}()
48
49    <-ctx.Done()
50    svr.Shutdown(context.Background())
51}

Conclusion

En utilisant ainsi un routeur centralisé pour gérer les erreurs, il est possible de réduire la charge liée à la vérification du code d'erreur et à la rédaction d'un message d'erreur approprié à chaque fois. De plus, en fournissant des messages d'erreur structurés à l'aide du RFC7807, il est possible d'aider le client à comprendre et à gérer les erreurs. Ces méthodes permettent de rendre la gestion des erreurs des API HTTP écrites en langage Go plus simple et plus cohérente.

Le code de cet article est disponible dans le référentiel gosuda/httpwrap.