GoSuda

Die weniger aufwendige Behandlung von HTTP-Fehlern + RFC7807

By snowmerak
views ...

Überblick

Bei der Erstellung einer http api in der Go-Sprache ist die Fehlerbehandlung das mühsamste Element. Ein repräsentatives Beispiel für solchen Code ist folgendes:

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}

Wenn die Anzahl der APIs gering ist, wird die Implementierung auf diese Weise wahrscheinlich keine wesentlichen Unannehmlichkeiten bereiten. Jedoch werden mit zunehmender Anzahl von APIs und wachsender Komplexität der internen Logik drei Aspekte störend.

  1. Die Rückgabe eines angemessenen Fehlercodes
  2. Die hohe Anzahl an resultierenden Log-Einträgen
  3. Die Übermittlung klar definierter Fehlermeldungen

Hauptteil

Die Rückgabe eines angemessenen Fehlercodes

Selbstverständlich ist Punkt 1, die Rückgabe eines angemessenen Fehlercodes, primär mein persönliches Ärgernis. Erfahrene Entwickler werden fähig sein, den passenden Code zu identifizieren und ihn jedes Mal korrekt einzufügen; jedoch können weniger versierte Entwickler, wie ich selbst, Schwierigkeiten damit haben, bei zunehmender Komplexität und Häufigkeit der Aufrufe konsistent die geeigneten Fehlercodes anzuwenden. Hierfür existieren verschiedene Lösungsansätze, wobei der prominenteste darin besteht, den API-Logikfluss vorab zu entwerfen und den Code entsprechend zu implementieren, um passende Fehler zurückzugeben. Tun Sie dies

Dies erscheint jedoch nicht als die optimale Methode für einen menschlichen Entwickler, der durch eine IDE (oder einen Language Server) unterstützt wird. Da zudem die REST API selbst darauf ausgelegt ist, die in den Fehlercodes enthaltene Semantik maximal auszunutzen, ließe sich ein alternativer Ansatz vorschlagen. Man könnte eine neue Implementierung des Fehler-(error)-Interfaces namens HttpError erstellen, die dazu dient, StatusCode und Message zu speichern. Daraufhin wird die folgende Helper-Funktion bereitgestellt:

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

Die BadRequest-Helper-Funktion wird ein HttpError-Objekt zurückgeben, bei dem der StatusCode auf 400 und die Message auf den als Argument übergebenen Wert gesetzt ist. Darüber hinaus sollten Helper-Funktionen wie NotImplement, ServiceUnavailable, Unauthorized und PaymentRequired selbstverständlich über die Autovervollständigungsfunktion auffindbar und hinzufügbar sein. Dies ist schneller, als jedes Mal die vorbereitete Spezifikation zu konsultieren, und zuverlässiger, als stets numerische Fehlercodes manuell einzugeben. Die stehen doch alle in den http.StatusCode-Konstanten? Pssst

Die hohe Anzahl an resultierenden Log-Einträgen

Bei Auftreten eines Fehlers ist es selbstverständlich, einen Log-Eintrag zu erstellen. Wenn man Logs für den API-Aufruf sowie für alle erwarteten Endpunkte – sei es Erfolg oder Misserfolg der Anfrage – von Beginn an setzt, erhöht sich die Menge des zu schreibenden Codes signifikant. Dies kann dadurch zentral verwaltet werden, dass der Handler selbst einmal umhüllt (gewrapped) wird.

Nachfolgend wird ein Beispiel für das Umhüllen (Wrapping) des chi-Routers dargestellt.

 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}

Die Router-Struktur ist so konzipiert, dass sie einen chi.Router intern hält und somit die Funktionalität des chi.Router unverändert beibehält. Wenn man sich die Get-Methode ansieht, wird geprüft, ob die zuvor vorgeschlagene Helper-Funktion ein HttpError-Objekt zurückgegeben hat, welches dann entsprechend weitergegeben wird; im Falle eines generischen error wird dieser einheitlich an die Fehler-Callback-Funktion übermittelt. Dieser Callback wird über den Konstruktor entgegengenommen.

Nachstehend ist der Code aufgeführt, der dieses Paket adaptiert.

 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}

Wie gefällt Ihnen dies? Wenn lediglich das HttpError als Helper-Funktion erstellt und zurückgegeben wird, ermöglicht dies die Verarbeitung im übergeordneten Scope, indem eine Antwort mit dem angemessenen Fehlercode und der Nachricht gesendet wird, während gleichzeitig ein Callback registriert werden kann, um für jeden Implementierungsservice die korrekten Protokolleinträge zu hinterlassen. Bei Bedarf ließe sich dies weiter erweitern, um eine detailliertere Protokollierung unter Verwendung von Elementen wie der RequestID zu ermöglichen.

Die Übermittlung klar definierter Fehlermeldungen

Das hierfür maßgebliche Dokument ist die RFC7807. Die RFC7807 definiert und verwendet hauptsächlich die folgenden Elemente:

  • type: Eine URI zur Identifizierung des Fehlertyps. Dient typischerweise als Dokumentation, die den Fehler erläutert.
  • title: Eine einzeilige Beschreibung, welche Art von Fehler vorliegt.
  • status: Identisch mit dem HTTP Status Code.
  • detail: Eine detaillierte, für Menschen lesbare Beschreibung des jeweiligen Fehlers.
  • instance: Die URI, an der der Fehler aufgetreten ist. Wenn beispielsweise bei GET /user/info ein Fehler auftritt, wäre /user/info der entsprechende Wert.
  • extensions: Zusätzliche Elemente zur Beschreibung des Fehlers, strukturiert als JSON Object.
    • Beispielsweise kann bei einem BadRequest die Benutzereingabe enthalten sein.
    • Oder bei TooManyRequest könnte der Zeitpunkt der letzten Anfrage inkludiert werden.

Um dies einfach anwenden zu können, wird eine neue Datei im selben Verzeichnis wie HttpError, nämlich im httperror-Paket, angelegt, um die RFC7807Error-Struktur zu definieren und die Erstellung mittels eines Method Chaining Patterns zu ermöglichen.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Standardtyp gemäß 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 { ... }

Der Wert "about:blank" für Type ist der Standardwert und bezeichnet eine nicht existierende Seite. Nachstehend ist ein Beispiel für die Erzeugung eines Fehlers bei einer fehlerhaften Anfrage dargestellt.

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

Mittels einfacher Method Chaining kann eine strukturierte Fehlermeldung für den Benutzer generiert werden. Des Weiteren kann die folgende Methode unterstützt werden, um den zuvor implementierten zentralisierten Router nutzen zu können:

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Falls das Marshaling fehlschlägt, wird auf die Verwendung der Detailinformation zurückgegriffen
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Bei direkter Anwendung dieser Methode auf das obige Beispiel ergibt sich folgende Modifikation:

 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}

Fazit

Durch die Nutzung eines derart zentralisierten Routers zur Fehlerbehandlung kann die Belastung reduziert werden, die mit der ständigen Überprüfung von Fehlercodes und der Formulierung angemessener Fehlermeldungen verbunden ist. Darüber hinaus kann durch die Implementierung der RFC7807 zur Bereitstellung strukturierter Fehlermeldungen der Klient darin unterstützt werden, die Fehler zu verstehen und angemessen darauf zu reagieren. Mittels dieser Vorgehensweise lässt sich die Fehlerbehandlung in mit der Go-Sprache entwickelten HTTP APIs vereinfachen und konsistenter gestalten.

Der Code zu diesem Artikel kann im gosuda/httpwrap Repository eingesehen werden.