GoSuda

Manejo más sencillo de errores HTTP + RFC7807

By snowmerak views ...

Descripción general

Al crear una API HTTP en el lenguaje Go, lo más tedioso es el manejo de errores. Un ejemplo representativo es el siguiente 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 la cantidad de APIs no es grande, escribir el código de esta manera no resultará especialmente incómodo. Sin embargo, a medida que aumenta el número de APIs y se vuelve más compleja la lógica interna, tres aspectos empiezan a resultar problemáticos.

  1. Devolución de códigos de error apropiados
  2. Cantidad excesiva de código para registrar resultados en los logs
  3. Envío de mensajes de error claros

Desarrollo

Devolución de códigos de error apropiados

Por supuesto, el punto 1, la devolución de códigos de error apropiados, es en cierta medida una queja personal mía. Un desarrollador experimentado buscará el código adecuado y lo incluirá correctamente cada vez, pero yo mismo, así como los desarrolladores que aún carecen de experiencia, pueden tener dificultades para utilizar de forma sistemática los códigos de error adecuados a medida que la lógica se complica y aumenta el número de llamadas. Existen diversos métodos para abordar esto; el más representativo consiste en diseñar previamente el flujo lógico de la API y escribir el código para devolver el error apropiado. Hágalo así

Sin embargo, este enfoque no parece ser el óptimo para un desarrollador humano que cuenta con la ayuda de un IDE (o de un Language Server). Además, dado que la propia REST API aprovecha al máximo el significado contenido en los códigos de error, se puede proponer otro método. Se crea una nueva implementación de la interfaz error denominada HttpError, que almacena StatusCode y Message. Asimismo, se proporcionan funciones auxiliares como la siguiente.

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

La función auxiliar BadRequest devolverá un HttpError cuyo StatusCode se establece en 400 y cuyo Message se establece con el valor recibido como argumento. Además, por supuesto, podrán consultarse y añadirse mediante autocompletado otras funciones auxiliares como NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired, etc. Esto resulta más rápido que consultar un diseño preparado cada vez y será más fiable que introducir los códigos de error como números cada vez. ¿Que todo está en las constantes http.StatusCode? Shh

Cantidad excesiva de código para registrar resultados en los logs

Al producirse un error, naturalmente se debe dejar un registro en el log. Al registrar en el log si una API fue invocada y si la solicitud tuvo éxito o fracasó, escribir registros en todos los puntos de salida previstos desde el inicio aumenta considerablemente la cantidad de código. Al envolver el propio handler, es posible gestionar esto de forma centralizada.

A continuación se muestra un ejemplo que envuelve el router 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 internamente un chi.Router, por lo que está configurada para utilizar directamente las funcionalidades de chi.Router. Si observa el método Get, verifica si se devolvió la estructura HttpError que devuelve la función auxiliar propuesta anteriormente, responde de forma adecuada y, en caso de tratarse de un error, lo transfiere de manera uniforme a la función callback de errores. Este callback se recibe mediante el constructor.

A continuación se muestra 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? Si simplemente se crea un HttpError mediante funciones auxiliares y se devuelve, el ámbito superior puede responder con el código de error y el mensaje apropiados, y al mismo tiempo registrar en cada servicio los logs adecuados mediante un callback registrado. Si fuera necesario, se puede ampliar para permitir un registro detallado utilizando, por ejemplo, un RequestID.

Envío de mensajes de error claros

Para ello existe el documento RFC7807. RFC7807 define y utiliza principalmente los siguientes elementos.

  • type: URI que identifica el tipo de error. Normalmente es un documento que describe el error.
  • title: Descripción breve de una línea sobre qué error se trata.
  • status: Es idéntico al código de estado HTTP.
  • detail: Descripción detallada y legible por humanos sobre dicho error.
  • instance: URI en la que se produjo el error. Por ejemplo, si el error se produjo en GET /user/info, el valor será /user/info.
  • extensions: Elemento secundario para describir el error, compuesto como un objeto JSON.
    • Por ejemplo, en el caso de BadRequest, puede incluir la entrada del usuario.
    • O bien, en el caso de TooManyRequest, puede incluir el momento de la solicitud más reciente.

Para facilitar su uso, se crea un nuevo archivo en el paquete httperror, ubicado junto a HttpError, se define la estructura RFC7807Error y se permite crearla mediante el 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 "about:blank" de Type es el valor 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")

Mediante un sencillo encadenamiento de métodos, es posible generar un mensaje de error estructurado para el usuario. Además, para utilizar el router centralizado creado anteriormente, se puede admitir el siguiente método.

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}

Si se utiliza tal cual y se modifica el ejemplo anterior, quedaría 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 manejar los errores mediante un router centralizado de este modo, se puede reducir la carga de verificar cada vez los códigos de error y de redactar mensajes de error apropiados. Además, al proporcionar mensajes de error estructurados mediante RFC7807, se puede ayudar al cliente a comprender y gestionar el error. Gracias a estos métodos, es posible hacer que el manejo de errores en las APIs HTTP escritas en Go sea más sencillo y coherente.

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