Gérer les erreurs HTTP de manière moins fastidieuse + RFC7807
Vue d'ensemble
Lors de la création d'une API HTTP en langage Go, la gestion des erreurs est la tâche la plus fastidieuse. Voici un exemple typique 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 devrait pas poser de problèmes majeurs. Cependant, à mesure que le nombre d'API augmente et que la logique interne se complexifie, trois aspects deviennent gênants :
- Retourner des codes d'erreur appropriés.
- Le grand nombre de logs de résultats à rédiger.
- Envoyer des messages d'erreur clairs.
Corps du texte
Retourner des codes d'erreur appropriés
Certes, le premier point, le retour de codes d'erreur appropriés, est une de mes préoccupations personnelles. Un développeur expérimenté saura trouver et insérer le code approprié à chaque fois, mais moi, comme d'autres développeurs encore inexpérimentés, pouvons avoir des difficultés à utiliser des codes d'erreur pertinents de manière régulière, surtout lorsque la logique se complexifie et que les requêtes se multiplient. Il existe plusieurs approches pour cela ; la plus courante consiste probablement à concevoir le flux logique de l'API à l'avance, puis à écrire le code pour retourner les erreurs appropriées. Faites-le.
Cependant, cela ne semble pas être la méthode optimale pour un développeur humain assisté par un IDE (ou un Language Server). De plus, étant donné que les API REST tirent le meilleur parti de la signification des codes d'erreur, une approche différente peut être proposée. Il s'agit de créer une nouvelle implémentation de l'interface error, appelée HttpError, qui stocke un StatusCode et un Message. Les fonctions d'aide suivantes sont alors fournies :
1err := httperror.BadRequest("wrong format")
La fonction d'aide BadRequest retournera un HttpError avec un StatusCode de 400 et un Message défini par la valeur passée en argument. En plus de cela, il sera bien sûr possible de consulter et d'ajouter des fonctions d'aide telles que NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired, etc., via la fonctionnalité d'auto-complétion. Cela sera plus rapide que de vérifier la spécification préparée à chaque fois, et plus stable que de saisir le code d'erreur numériquement à chaque occasion. Vous dites que tous les codes sont dans http.StatusCode ? Chut.
Nombre élevé de logs de résultats à rédiger
En cas d'erreur, il est naturel de laisser des logs. Lorsque des API sont appelées et que l'on enregistre si la requête a réussi ou échoué, laisser des logs à partir du début jusqu'à tous les points de terminaison anticipés augmente la quantité de code à écrire. Cela peut être géré de manière centralisée en enveloppant le gestionnaire lui-même.
Voici un exemple d'encapsulation du 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 // Si le gestionnaire retourne une erreur
32 if err := handler(writer, request); err != nil {
33 he := &httperror.HttpError{}
34 // Vérifier si l'erreur est un HttpError
35 switch errors.As(err, &he) {
36 case true:
37 // Si c'est un HttpError, utiliser son message et son code
38 http.Error(writer, he.Message, he.Code)
39 case false:
40 // Sinon, utiliser le message d'erreur générique et un InternalServerError
41 http.Error(writer, err.Error(), http.StatusInternalServerError)
42 }
43 // Appeler la fonction de rappel d'erreur
44 r.errCallback(err)
45 }
46 })
47}
La structure du routeur contient chi.Router en interne, ce qui permet d'utiliser les fonctionnalités de chi.Router telles quelles. La méthode Get vérifie si la structure HttpError, retournée par la fonction d'aide que nous venons de proposer, a été renvoyée. Si c'est le cas, elle est renvoyée de manière appropriée ; sinon, si c'est une error générique, elle est transmise uniformément à la fonction de rappel d'erreur. Ce rappel est fourni via le constructeur.
Voici le code écrit en utilisant ce package :
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10 "os" // Ajout de l'importation pour os
11 "os/signal" // Ajout de l'importation pour os/signal
12 "syscall" // Ajout de l'importation pour syscall
13
14 "github.com/gosuda/httpwrap/httperror"
15 "github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19 // Configuration du contexte pour la gestion des signaux d'interruption
20 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
21 defer cancel()
22
23 // Création d'un nouveau routeur avec une fonction de rappel d'erreur
24 r := chiwrap.NewRouter(func(err error) {
25 log.Printf("Router log test: Error occured: %v", err)
26 })
27 // Définition d'un gestionnaire GET pour le chemin "/echo"
28 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
29 // Récupération du paramètre de requête "name"
30 name := request.URL.Query().Get("name")
31 // Si le nom est vide, retourner une erreur BadRequest
32 if name == "" {
33 return httperror.BadRequest("name is required")
34 }
35
36 // Écrire la réponse "Hello " + name
37 writer.Write([]byte("Hello " + name))
38 return nil
39 })
40
41 // Configuration du serveur HTTP
42 svr := http.Server{
43 Addr: ":8080", // Adresse d'écoute
44 Handler: r, // Utilisation du routeur comme gestionnaire
45 }
46 // Démarrage du serveur dans une goroutine séparée
47 go func() {
48 // Écouter les requêtes, en ignorant l'erreur ServerClosed lors de l'arrêt
49 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
50 log.Fatalf("Failed to start server: %v", err)
51 }
52 }()
53
54 // Attendre que le contexte soit annulé (signal d'interruption reçu)
55 <-ctx.Done()
56 // Arrêt du serveur
57 svr.Shutdown(context.Background())
58}
Qu'en pensez-vous ? Si seul HttpError est créé et retourné en tant que fonction d'aide, il est possible de gérer les réponses avec des codes d'erreur et des messages appropriés dans la portée supérieure, tout en enregistrant des rappels pour que chaque service d'implémentation puisse générer des logs pertinents. Si nécessaire, il serait possible d'étendre cette fonctionnalité pour permettre un logging détaillé en utilisant, par exemple, un RequestID.
Transmission de messages d'erreur clairs
Le document RFC7807 est pertinent à cet égard. Le RFC7807 définit principalement les éléments suivants :
type: Un URI identifiant le type d'erreur. Il s'agit généralement d'un document expliquant l'erreur.title: Une description succincte de l'erreur.status: Identique au code de statut HTTP.detail: Une description détaillée de l'erreur, lisible par un humain.instance: L'URI où l'erreur s'est produite. Par exemple, si une erreur s'est produite lors deGET /user/info, la valeur serait/user/info.extensions: Des éléments secondaires sous forme d'objet JSON pour décrire l'erreur.- Par exemple, en cas de
BadRequest, l'entrée de l'utilisateur peut être incluse. - Ou, en cas de
TooManyRequest, l'heure de la requête la plus récente peut être incluse.
- Par exemple, en cas de
Pour faciliter son utilisation, nous créons un nouveau fichier dans le package httperror, au même endroit que HttpError, et nous y définissons la structure RFC7807Error, qui peut être créée 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 { ... }
Le Type "about:blank" est la valeur par défaut. Il signifie une page inexistante. Voici un exemple de création d'une 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 chaînage de méthodes simple permet de générer des messages d'erreur structurés pour l'utilisateur. De plus, la méthode suivante peut être prise en charge pour utiliser le routeur centralisé mentionné précédemment :
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 // Marshaller la structure RFC7807Error en JSON
3 jsonBytes, err := json.Marshal(p)
4 if err != nil {
5 // Si le marshalling échoue, revenir à l'utilisation du détail
6 return New(p.Status, p.Detail)
7 }
8 // Retourner un nouvel HttpError avec le statut et le corps JSON
9 return New(p.Status, string(jsonBytes))
10}
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 "os" // Ajout de l'importation pour os
11 "os/signal" // Ajout de l'importation pour os/signal
12 "syscall" // Ajout de l'importation pour syscall
13
14 "github.com/gosuda/httpwrap/httperror"
15 "github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19 // Configuration du contexte pour la gestion des signaux d'interruption
20 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
21 defer cancel()
22
23 // Création d'un nouveau routeur avec une fonction de rappel d'erreur
24 r := chiwrap.NewRouter(func(err error) {
25 log.Printf("Router log test: Error occured: %v", err)
26 })
27 // Définition d'un gestionnaire GET pour le chemin "/echo"
28 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
29 // Récupération du paramètre de requête "name"
30 name := request.URL.Query().Get("name")
31 // Si le nom est vide, retourner une erreur BadRequestProblem structurée
32 if name == "" {
33 return httperror.BadRequestProblem("name is required", "Bad User Input").
34 WithType("https://example.com/errors/validation").
35 WithInstance("/api/echo").
36 WithExtension("invalid_field", "name").
37 WithExtension("expected_format", "string").
38 WithExtension("actual_value", name).
39 ToHttpError() // Conversion en HttpError
40 }
41
42 // Écrire la réponse "Hello " + name
43 writer.Write([]byte("Hello " + name))
44 return nil
45 })
46
47 // Configuration du serveur HTTP
48 svr := http.Server{
49 Addr: ":8080", // Adresse d'écoute
50 Handler: r, // Utilisation du routeur comme gestionnaire
51 }
52 // Démarrage du serveur dans une goroutine séparée
53 go func() {
54 // Écouter les requêtes, en ignorant l'erreur ServerClosed lors de l'arrêt
55 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
56 log.Fatalf("Failed to start server: %v", err)
57 }
58 }()
59
60 // Attendre que le contexte soit annulé (signal d'interruption reçu)
61 <-ctx.Done()
62 // Arrêt du serveur
63 svr.Shutdown(context.Background())
64}
Conclusion
En utilisant ce routeur centralisé pour gérer les erreurs, la charge liée à la vérification constante des codes d'erreur et à la rédaction de messages d'erreur appropriés peut être réduite. De plus, en fournissant des messages d'erreur structurés via le RFC7807, il est possible d'aider les clients à comprendre et à traiter les erreurs. Cette approche permet de rendre la gestion des erreurs des API HTTP écrites en Go plus simple et plus cohérente.
Le code de cet article est disponible dans le dépôt gosuda/httpwrap.