GoSuda

Manejo de errores HTTP de manera menos engorrosa + RFC7807

By snowmerak
views ...

Resumen

Al crear http api en el lenguaje Go, lo más tedioso es el manejo de errores. Un ejemplo típico es 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) // Log de error
6        return
7    }
8    // ... // Lógica de éxito
9}

Si no hay muchas API, escribir de esta manera no será particularmente inconveniente. Sin embargo, a medida que el número de API aumenta y la lógica interna se vuelve más compleja, tres cosas se vuelven molestas.

  1. Devolución de códigos de error apropiados
  2. Alto volumen de escritura de logs de resultados
  3. Envío de mensajes de error claros

Cuerpo principal

Devolución de códigos de error apropiados

Por supuesto, el punto 1, la devolución de códigos de error apropiados, es una queja personal mía. Un desarrollador experimentado encontrará el código apropiado y lo insertará correctamente cada vez, pero los desarrolladores inexpertos como yo pueden tener dificultades para usar el código de error apropiado de manera regular a medida que la lógica se complica y el número de llamadas aumenta. Habrá varias formas de abordar esto, y la más típica es diseñar el flujo lógico de la API de antemano y luego escribir el código para devolver el error apropiado. Haga eso

Sin embargo, esto no parece ser el método óptimo para los desarrolladores humanos que reciben asistencia de un IDE (o Language Server). Además, dado que la propia REST API maximiza el uso del significado contenido en los códigos de error, se puede proponer otro método. Cree una nueva implementación de la interfaz de error (error) llamada HttpError para almacenar StatusCode y Message. Y proporcione funciones de ayuda como la siguiente.

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

La función de ayuda BadRequest devolverá un HttpError con el StatusCode establecido en 400 y el Message establecido al valor recibido como argumento. Además de esto, por supuesto, las funciones de ayuda como NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired, etc., se pueden consultar y añadir utilizando la función de auto-completado. Esto será más rápido que verificar el documento de diseño preparado cada vez, y más estable que ingresar códigos de error numéricamente cada vez. ¿Los constantes http.StatusCode los tienen todos? Shh

Alto volumen de escritura de logs de resultados

Cuando ocurre un error, naturalmente se dejan logs. Al registrar si una llamada API y una solicitud tuvieron éxito o fallaron, registrar en todos los puntos de salida esperados desde el principio aumenta la cantidad de código a escribir. Al envolver el propio handler una vez, se puede gestionar de forma centralizada.

El siguiente es un ejemplo de cómo envolver 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: // Si es de tipo HttpError
35				http.Error(writer, he.Message, he.Code)
36			case false: // En caso de otro error
37				http.Error(writer, err.Error(), http.StatusInternalServerError)
38			}
39			r.errCallback(err) // Ejecutar el callback de error registrado al crear el router
40		}
41	})
42}

La struct del router tiene chi.Router internamente, por lo que está configurada para usar las funciones de chi.Router tal cual. Si observa el método Get, después de verificar si se ha devuelto la struct HttpError que devuelve la función de ayuda que acabamos de sugerir, la devuelve apropiadamente, y en caso de un error, se pasa uniformemente a la función de callback de error. Este callback se recibe a través del constructor.

El siguiente es código escrito utilizando este paquete.

 1package main
 2
 3import (
 4	"bytes"
 5	"context"
 6	"errors"
 7	"io"
 8	"log"
 9	"net/http"
10	"os"
11	"os/signal"
12	"syscall"
13
14	"github.com/gosuda/httpwrap/httperror"
15	"github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
20    defer cancel()
21
22	r := chiwrap.NewRouter(func(err error) { // Imprimir log cuando ocurre un error
23		log.Printf("Router log test: Error occured: %v", err)
24	})
25	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
26		name := request.URL.Query().Get("name")
27		if name == "" {
28			return httperror.BadRequest("name is required") // Devolver objeto de error con función de ayuda
29		}
30
31		writer.Write([]byte("Hello " + name)) // Devolver nil en caso de éxito
32		return nil
33	})
34
35	svr := http.Server{
36		Addr:    ":8080",
37		Handler: r,
38	}
39	go func() {
40		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
41			log.Fatalf("Failed to start server: %v", err)
42		}
43	}()
44
45    <-ctx.Done()
46    svr.Shutdown(context.Background())
47}

¿Qué le parece? Si simplemente crea y devuelve HttpError como una función de ayuda, puede registrar un callback en el scope superior para manejarlo, permitiendo que se dejen logs apropiados para cada servicio implementado mientras se devuelven respuestas con códigos de error y mensajes apropiados. Si es necesario adicionalmente, se puede extender para habilitar logging detallado utilizando RequestID, etc.

Envío de mensajes de error claros

El documento para esto es RFC7807. RFC7807 se utiliza principalmente definiendo los siguientes elementos.

  • type: URI que identifica el tipo de error. Es principalmente un documento que describe el error.
  • title: Una descripción de una línea de cuál es el error.
  • status: Igual que HTTP Status Code.
  • detail: Una explicación detallada del error que es legible por humanos.
  • instance: El URI donde ocurrió el error. Por ejemplo, si ocurrió un error en GET /user/info, /user/info será ese valor.
  • extensions: Elementos secundarios configurados como un JSON Object para describir el error.
    • Por ejemplo, en el caso de BadRequest, se puede incluir la entrada del usuario.
    • O en el caso de TooManyRequest, se puede incluir la hora de la solicitud más reciente.

Para usar esto fácilmente, cree un nuevo archivo en el paquete httperror, que está en la misma ubicación que HttpError, cree la struct RFC7807Error y permita que se cree utilizando el patrón method chaining.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Tipo predeterminado según 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 { // Configurar tipo
19	p.Type = typeURI
20	return p
21}
22func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { // Configurar instancia
23	p.Instance = instance
24	return p
25}
26func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { // Añadir campo de extensión
27	if p.Extensions == nil {
28		p.Extensions = make(map[string]interface{})
29	}
30	p.Extensions[key] = value
31	return p
32}

El "about:blank" para Type es el valor predeterminado. Significa una página inexistente. A continuación, se muestra un ejemplo de generación de 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")

Se pueden generar mensajes de error estructurados para el usuario con un simple method chaining. Además, para utilizar el router centralizado escrito anteriormente, se pueden soportar los siguientes métodos.

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Si el marshaling falla, recurrir a usar solo el detail
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Modificando el ejemplo anterior utilizando esto tal cual, resulta en lo siguiente.

 1package main
 2
 3import (
 4	"bytes"
 5	"context"
 6	"errors"
 7	"io"
 8	"log"
 9	"net/http"
10	"os"
11	"os/signal"
12	"syscall"
13
14	"github.com/gosuda/httpwrap/httperror"
15	"github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
20    defer cancel()
21
22	r := chiwrap.NewRouter(func(err error) {
23		log.Printf("Router log test: Error occured: %v", err)
24	})
25	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
26		name := request.URL.Query().Get("name")
27		if name == "" {
28			return httperror.BadRequestProblem("name is required", "Bad User Input").
29                WithType("https://example.com/errors/validation").
30                WithInstance("/api/echo").
31                WithExtension("invalid_field", "name").
32                WithExtension("expected_format", "string").
33                WithExtension("actual_value", name).
34                ToHttpError() // Convertir a tipo HttpError y devolver
35		}
36
37		writer.Write([]byte("Hello " + name))
38		return nil
39	})
40
41	svr := http.Server{
42		Addr:    ":8080",
43		Handler: r,
44	}
45	go func() {
46		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
47			log.Fatalf("Failed to start server: %v", err)
48		}
49	}()
50
51    <-ctx.Done()
52    svr.Shutdown(context.Background())
53}

Conclusión

Al manejar errores utilizando un router centralizado de esta manera, se puede reducir la carga de verificar los códigos de error y escribir mensajes de error apropiados cada vez. Además, al proporcionar mensajes de error estructurados utilizando RFC7807, se puede ayudar a los clientes a comprender y manejar los errores. A través de estos métodos, el manejo de errores en las HTTP API escritas en el lenguaje Go se puede hacer más simple y consistente.

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