GoSuda

Tratamento de erros HTTP de modo menos oneroso + RFC7807

By snowmerak
views ...

Visão Geral

Ao gerar uma api http na linguagem Go, o aspecto mais tedioso reside no tratamento de erros. Um código representativo é o seguinte.

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}

Se o número de APIs for reduzido, escrever desta forma não causará transtornos significativos. Contudo, à medida que o número de APIs aumenta e a lógica interna se torna mais complexa, os seguintes três pontos tornam-se problemáticos:

  1. Retorno de códigos de erro apropriados
  2. Grande volume de escrita de logs de resultado
  3. Transmissão de mensagens de erro explícitas

Conteúdo Principal

Retorno de Códigos de Erro Apropriados

Naturalmente, o item 1, o retorno de códigos de erro apropriados, constitui uma queixa pessoal minha. Embora desenvolvedores experientes encontrem e insiram os códigos adequados consistentemente, desenvolvedores menos experientes, como eu, podem enfrentar dificuldades em aplicar códigos de erro adequados de maneira metódica à medida que a lógica se complexifica e as iterações se multiplicam. Existem diversas abordagens para isso, sendo a mais representativa a elaboração de código que retorna erros apropriados após o projeto prévio do fluxo da lógica da API. Faça exatamente isso

Entretanto, este não parece ser o método otimizado para um desenvolvedor humano auxiliado por um IDE (ou Language Server). Além disso, dado que a própria REST API visa maximizar a utilização do significado contido no código de erro, uma abordagem alternativa pode ser proposta: criar uma nova implementação da interface de erro (error) denominada HttpError, configurada para armazenar StatusCode e Message. E fornecemos a seguinte função auxiliar:

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

A função auxiliar BadRequest retornará um HttpError com StatusCode definido como 400 e Message definido como o valor passado como argumento. Além disso, funções auxiliares como NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired, etc., certamente poderão ser consultadas e adicionadas por meio da funcionalidade de autocompletar. Isso é mais rápido do que verificar um documento de projeto preparado a cada vez e mais robusto do que inserir códigos de erro numericamente repetidamente. Os constantes de http.StatusCode contêm todos eles? Silêncio

Grande Volume de Escrita de Logs de Resultado

É imperativo registrar logs quando ocorre um erro. Ao registrar logs sobre se uma API foi invocada e se a requisição foi bem-sucedida ou falhou, registrar logs em todos os pontos de término previstos desde o início aumenta a quantidade de código a ser escrito. Isso pode ser centralmente gerenciado ao envolver o próprio handler.

A seguir, apresentamos um exemplo de como envolver o 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}

A estrutura struct Router é configurada para possuir internamente um chi.Router, utilizando assim as funcionalidades deste último de forma integral. Ao examinar o método Get, verifica-se se foi retornado o objeto estrutural HttpError produzido pela função auxiliar sugerida anteriormente, retornando-o adequadamente, ou, no caso de ser um error genérico, ele é repassado de forma unificada à função de callback de erro. Este callback é recebido via construtor.

O código a seguir demonstra a utilização deste pacote.

 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}

O que acham? Ao simplesmente criar e retornar um HttpError através de uma função auxiliar, é possível tratar a resposta no escopo superior com o código de erro e a mensagem adequados, enquanto se registra um callback para que logs apropriados sejam mantidos para cada serviço implementado. Adicionalmente, se necessário, é possível expandir essa funcionalidade para permitir um registro detalhado utilizando, por exemplo, um RequestID.

Transmissão de Mensagens de Erro Explícitas

Para este fim, existe o documento RFC7807. O RFC7807 define e utiliza primariamente os seguintes elementos:

  • type: Um URI que identifica o tipo de erro. Geralmente é um documento que descreve o erro.
  • title: Uma descrição concisa sobre qual é o erro.
  • status: Idêntico ao HTTP Status Code.
  • detail: Uma descrição detalhada e legível por humanos sobre o erro em questão.
  • instance: O URI onde o erro ocorreu. Por exemplo, se o erro ocorreu em GET /user/info, /user/info será o seu valor.
  • extensions: Elementos secundários configurados como um Objeto JSON para descrever o erro.
    • Por exemplo, no caso de BadRequest, a entrada do usuário pode ser incluída.
    • Ou, no caso de TooManyRequest, pode-se incluir o momento da última requisição.

Para facilitar o uso disto, criamos um novo arquivo no pacote httperror, no mesmo nível do HttpError, para gerar a estrutura RFC7807Error utilizando o padrão de encadeamento de métodos (method chaining).

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Tipo padrão conforme 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 { ... }

O valor "about:blank" para Type é o padrão, significando uma página inexistente. A seguir, apresentamos um exemplo de geração de erro para uma requisição inválida.

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

É possível gerar uma mensagem de erro estruturada para o usuário através de um simples encadeamento de métodos. Além disso, para aproveitar o router centralizado previamente elaborado, o seguinte método pode ser suportado.

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Se a serialização falhar, retorna apenas o detalhe
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Ao utilizar isto diretamente, a modificação do exemplo anterior resulta no seguinte.

 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}

Conclusão

Ao processar erros utilizando um router centralizado desta maneira, é possível reduzir a carga associada à verificação contínua dos códigos de erro e à redação de mensagens de erro adequadas. Além disso, ao empregar o RFC7807 para fornecer mensagens de erro estruturadas, pode-se auxiliar o cliente na compreensão e tratamento dos erros. Por meio dessas metodologias, o tratamento de erros em APIs HTTP desenvolvidas na linguagem Go pode se tornar mais simplificado e consistente.

O código deste artigo pode ser verificado no repositório gosuda/httpwrap.