GoSuda

HTTP-Fehler bequemer behandeln + RFC7807

By snowmerak views ...

Überblick

Beim Erstellen einer HTTP-API mit der Programmiersprache Go ist die Fehlerbehandlung das lästigste Element. Typischerweise sieht der Code so aus.

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}

Solange die Anzahl der APIs gering ist, dürfte es kaum unbequem sein, auf diese Weise zu schreiben. Mit wachsender Anzahl von APIs und zunehmender Komplexität der internen Logik treten jedoch drei Punkte störend in den Vordergrund.

  1. Rückgabe geeigneter Fehlercodes
  2. Anzahl der Schreibvorgänge für Ergebnisprotokolle
  3. Übermittlung klarer Fehlermeldungen

Hauptteil

Rückgabe geeigneter Fehlercodes

Der erste Punkt, die Rückgabe geeigneter Fehlercodes, ist zwar eine persönliche Unzufriedenheit meinerseits. Erfahrene Entwickler werden stets den passenden Code finden und konsequent einsetzen. Ich selbst und auch unerfahrene Entwickler haben jedoch mit zunehmender Komplexität der Logik und steigender Anzahl von Aufrufen oft Schwierigkeiten, geeignete Fehlercodes regelmäßig und einheitlich zu verwenden. Dafür gibt es verschiedene Ansätze, der repräsentativste besteht darin, den Ablauf der API-Logik im Voraus zu entwerfen und den Code anschließend so zu schreiben, dass passende Fehler zurückgegeben werden. Machen Sie es so

Dies erscheint jedoch nicht als optimale Methode für menschliche Entwickler, die von einer IDE (oder einem Language Server) unterstützt werden. Da REST-APIs zudem die Bedeutung von Fehlercodes möglichst umfassend nutzen, lässt sich ein anderer Ansatz vorschlagen. Man erstellt eine neue Implementierung des Interfaces error namens HttpError, die StatusCode und Message speichert. Außerdem stellt man folgende Hilfsfunktionen bereit.

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

Die Hilfsfunktion BadRequest gibt ein HttpError zurück, dessen StatusCode auf 400 und dessen Message auf den übergebenen Wert gesetzt ist. Darüber hinaus können selbstverständlich weitere Hilfsfunktionen wie NotImplement, ServiceUnavailable, Unauthorized oder PaymentRequired über die automatische Vervollständigung abgefragt und ergänzt werden. Dies ist schneller als das wiederholte Nachschlagen in einem vorbereiteten Entwurf und zuverlässiger als die jedes Mal manuelle Eingabe numerischer Fehlercodes. Alles bereits in der Konstante http.StatusCode enthalten? Pst

Anzahl der Schreibvorgänge für Ergebnisprotokolle

Bei einem Fehler wird naturgemäß ein Protokoll geschrieben. Beim Protokollieren, ob ein API-Aufruf erfolgreich oder fehlgeschlagen ist, wächst die Menge des zu schreibenden Codes erheblich, wenn an allen erwarteten Endpunkten vom Beginn an Protokolleinträge gesetzt werden. Indem man den Handler selbst einmal umhüllt, lässt sich dies zentral verwalten.

Das folgende Beispiel zeigt eine Umhüllung des Routers 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}

Die Routerstruktur enthält chi.Router intern, sodass die Funktionalität von chi.Router unverändert genutzt werden kann. In der Methode Get wird geprüft, ob das von der oben vorgeschlagenen Hilfsfunktion zurückgegebene Strukturobjekt HttpError ist, und es wird entsprechend geantwortet. Im Fall eines error wird dieser einheitlich an die Fehler-Callback-Funktion übergeben. Dieser Callback wird über den Konstruktor entgegengenommen.

Das folgende Beispiel zeigt Code, der dieses Paket verwendet.

 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 finden Sie das? Wenn man lediglich HttpError als Hilfsfunktion erstellt und zurückgibt, kann der übergeordnete Bereich mit einem passenden Fehlercode und einer passenden Meldung antworten, während durch die Registrierung eines Callbacks jeder Dienst ein geeignetes Protokoll schreiben kann. Bei Bedarf lässt sich dies erweitern, sodass beispielsweise über RequestID eine detaillierte Protokollierung möglich wird.

Übermittlung klarer Fehlermeldungen

Hierfür gibt es das Dokument RFC7807. RFC7807 definiert und verwendet hauptsächlich folgende Elemente.

  • type: Ein URI, der den Fehlertyp identifiziert. In der Regel ist dies ein Dokument, das den Fehler beschreibt.
  • title: Eine kurze einzeilige Beschreibung des Fehlers.
  • status: Entspricht dem HTTP-Statuscode.
  • detail: Eine für Menschen lesbare, ausführliche Beschreibung des jeweiligen Fehlers.
  • instance: Der URI, bei dem der Fehler aufgetreten ist. Tritt beispielsweise bei GET /user/info ein Fehler auf, so lautet der Wert /user/info.
  • extensions: Ergänzende Elemente zur Beschreibung des Fehlers, die als JSON-Objekt aufgebaut sind.
    • Bei BadRequest können beispielsweise die Eingaben des Benutzers enthalten sein.
    • Bei TooManyRequest kann etwa der Zeitpunkt der letzten Anfrage enthalten sein.

Um dies einfach zu verwenden, erstellt man im Paket httperror, an derselben Stelle wie HttpError, eine neue Datei, definiert die Struktur RFC7807Error und ermöglicht die Erstellung im Methodenverkettungsmuster.

 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 { ... }

Der Wert "about:blank" für Type ist der Standardwert. Er bedeutet, dass keine Seite existiert. Im Folgenden ein Beispiel für die Erstellung eines Fehlers bei einer ungültigen Anfrage.

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

Mit einfacher Methodenverkettung lässt sich eine strukturierte Fehlermeldung für den Benutzer erzeugen. Um außerdem den zuvor geschriebenen zentralisierten Router zu nutzen, kann man zusätzlich folgende Methode bereitstellen.

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}

Verwendet man dies unverändert, um das obige Beispiel anzupassen, ergibt sich folgender Code.

 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 Fehlerbehandlung über einen zentralisierten Router wird die Belastung verringert, bei jedem Aufruf Fehlercodes zu prüfen und passende Fehlermeldungen zu formulieren. Zudem kann durch die Verwendung von RFC7807 eine strukturierte Fehlermeldung bereitgestellt werden, die Clients beim Verstehen und Verarbeiten des Fehlers unterstützt. Auf diese Weise lässt sich die Fehlerbehandlung in in Go geschriebenen HTTP-APIs deutlich einfacher und einheitlicher gestalten.

Den Code dieses Artikels finden Sie im Repository gosuda/httpwrap.