GoSuda

Manejo de errores HTTP con menos molestias + RFC7807

By snowmerak
views ...

Descripción general

Al crear una API HTTP en el lenguaje Go, el manejo de errores es la tarea más tediosa. Por ejemplo, existe este código:

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 el número de APIs no es elevado, escribir el código de esta manera no debería causar ninguna molestia. Sin embargo, a medida que el número de APIs aumenta y la lógica interna se vuelve más compleja, tres aspectos se vuelven problemáticos:

  1. Devolución de códigos de error apropiados
  2. Gran cantidad de registros de resultados
  3. Envío de mensajes de error claros

Cuerpo

Devolución de códigos de error apropiados

Ciertamente, el punto 1, la devolución de códigos de error apropiados, es una queja personal. Un desarrollador experimentado encontrará e insertará los códigos correctos en cada ocasión; sin embargo, tanto yo como los desarrolladores menos experimentados podemos enfrentar dificultades para utilizar códigos de error adecuados de manera consistente a medida que la lógica se complica y las recuperaciones aumentan. Existen diversos enfoques para abordar esto, siendo el más común diseñar previamente el flujo lógico de la API y luego escribir el código para devolver los errores adecuados. Haga eso

Sin embargo, esto no parece ser el método óptimo para un desarrollador humano que cuenta con la asistencia de un IDE (o un Language Server). Además, dado que la propia API REST aprovecha al máximo el significado de los códigos de error, se podría proponer un enfoque alternativo. Se crea una nueva implementación de la interfaz error denominada HttpError, la cual almacena el StatusCode y el Message. Y se proporcionan las siguientes funciones de ayuda:

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

La función auxiliar BadRequest devolverá un HttpError con el StatusCode establecido en 400 y el Message configurado con el valor proporcionado como argumento. Adicionalmente, será posible consultar y añadir automáticamente funciones auxiliares como NotImplement, ServiceUnavailable, Unauthorized y PaymentRequired mediante la función de autocompletado. Esto será más rápido que verificar el diseño preparado en cada ocasión, y más estable que introducir códigos de error numéricos de forma recurrente. ¿Están todos en la constante http.StatusCode? Shhh

Gran cantidad de registros de resultados

Naturalmente, cuando ocurre un error, se genera un registro. Al registrar si una llamada a la API fue exitosa o fallida, registrar en cada punto de terminación esperado desde el inicio aumenta la cantidad de código a escribir. Esto se puede gestionar centralizadamente envolviendo el propio Handler.

A continuación, se presenta un ejemplo de cómo envolver el enrutador 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 estructura Router contiene un chi.Router internamente, configurado para utilizar las funcionalidades de chi.Router directamente. Observando el método Get, se verifica si se ha devuelto una estructura HttpError por la función auxiliar propuesta anteriormente, se devuelve adecuadamente y, en caso de ser un error, se transfiere de forma unificada a la función de callback de error. Este callback se recibe a través del constructor.

A continuación, se presenta el código escrito utilizando este paquete:

 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}

¿Qué le parece? Simplemente al crear y devolver un HttpError como una función auxiliar, el ámbito superior puede responder con un código de error y un mensaje apropiados, y se puede registrar un callback para permitir un registro adecuado por cada servicio implementado. Adicionalmente, si es necesario, se puede extender para permitir un registro detallado utilizando elementos como RequestID.

Envío de mensajes de error claros

Para esto, existe el documento RFC7807. RFC7807 define y utiliza principalmente los siguientes elementos:

  • type: URI que identifica el tipo de error. Generalmente es un documento que describe el error.
  • title: Una descripción concisa del tipo de error.
  • status: Equivalente al HTTP Status Code.
  • detail: Una descripción detallada del error, legible por humanos.
  • instance: URI donde ocurrió el error. Por ejemplo, si un error ocurre en GET /user/info, su valor sería /user/info.
  • extensions: Elementos secundarios que describen el error, estructurados como un objeto JSON.
    • Por ejemplo, en caso de un BadRequest, podría incluir la entrada del usuario.
    • O en caso de un TooManyRequest, podría incluir la hora de la solicitud más reciente.

Para facilitar su uso, se crea un nuevo archivo en el paquete httperror, en la misma ubicación que HttpError, y se genera la estructura RFC7807Error, permitiendo su creación mediante un patrón de encadenamiento de métodos.

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

El valor "about:blank" para Type es el predeterminado. Significa una página inexistente. A continuación, se muestra un ejemplo de creación de un error para una solicitud incorrecta.

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

Es posible generar mensajes de error estructurados para el usuario mediante un encadenamiento de métodos sencillo. Además, para utilizar el enrutador centralizado previamente escrito, se pueden admitir los siguientes métodos:

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}

Al utilizar esto directamente, el ejemplo anterior se modifica de la siguiente manera:

 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}

Conclusión

Al procesar errores mediante un enrutador centralizado, se puede mitigar la carga de verificar códigos de error y redactar mensajes de error apropiados en cada ocasión. Además, al proporcionar mensajes de error estructurados utilizando RFC7807, se puede asistir al cliente en la comprensión y el manejo de los errores. Este método permite que el procesamiento de errores de las API HTTP escritas en Go sea más sencillo y consistente.

El código de este artículo se puede encontrar en el repositorio gosuda/httpwrap.