Tratamento de erros HTTP de forma menos trabalhosa + RFC7807

Visão geral
Ao criar uma API HTTP em Go, a parte mais trabalhosa é o tratamento de erros. Um exemplo típico é o seguinte 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}
Se a quantidade de APIs for pequena, escrever dessa forma não causará grandes inconveniências. No entanto, à medida que o número de APIs aumenta e a lógica interna se torna mais complexa, três aspectos passam a incomodar.
- Retorno de códigos de erro apropriados
- Quantidade excessiva de código para registro de logs
- Envio de mensagens de erro claras
Desenvolvimento
Retorno de códigos de erro apropriados
Na verdade, o primeiro ponto, o retorno de códigos de erro apropriados, é uma queixa pessoal minha. Um desenvolvedor experiente encontrará o código adequado e o inserirá corretamente a cada vez, mas eu mesmo, assim como desenvolvedores ainda inexperientes, podemos ter dificuldade em utilizar de forma consistente os códigos de erro adequados à medida que a lógica se torna complexa e o número de chamadas aumenta. Existem diversos métodos para isso, sendo o mais representativo projetar previamente o fluxo lógico da API e, em seguida, escrever o código para retornar os erros apropriados. Faça assim
Contudo, essa abordagem não parece ser a mais eficiente para um desenvolvedor humano que se beneficia do IDE (ou do Language Server). Além disso, como a própria REST API aproveita ao máximo o significado contido nos códigos de erro, pode-se propor outra forma. Criaremos uma nova implementação da interface de erro (error) chamada HttpError, que armazena StatusCode e Message. Em seguida, fornecemos funções auxiliares como a seguinte.
1err := httperror.BadRequest("wrong format")
A função auxiliar BadRequest retornará um HttpError cujo StatusCode é 400 e cujo Message é definido com o valor recebido como argumento. Além dela, naturalmente, funções auxiliares como NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired etc. poderão ser consultadas e adicionadas com o recurso de autocompletar. Isso é mais rápido do que consultar um projeto preparado a cada vez e mais confiável do que digitar manualmente códigos de erro numéricos. Dizem que tudo isso já está nas constantes de http.StatusCode? Shh
Quantidade excessiva de código para registro de logs
Ao ocorrer um erro, é natural registrar um log. Ao registrar logs sobre a chamada da API e sobre o sucesso ou a falha da requisição, registrar logs em todos os pontos de saída previstos, desde o início, aumenta a quantidade de código a ser escrito. Ao envolver o próprio handler, é possível gerenciar isso de forma centralizada.
A seguir, um exemplo que envolve o roteador 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 do roteador contém um chi.Router internamente, de modo que os recursos do chi.Router podem ser utilizados diretamente. Observando o método Get, ele verifica se foi retornado um HttpError, como as funções auxiliares propostas acima, respondendo de forma apropriada; caso contrário, em todos os casos de error, a função de callback de erro é chamada. Esse callback é recebido pelo construtor.
A seguir, o código escrito utilizando este 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}
E então? Basta criar apenas o HttpError como função auxiliar e retorná-lo: o escopo superior responde com o código de erro e a mensagem apropriados, e cada serviço de implementação pode registrar os logs adequados, registrando um callback para tratá-los. Se necessário, pode-se ampliar isso para permitir registros detalhados utilizando, por exemplo, um RequestID.
Envio de mensagens de erro claras
Para isso, existe o documento RFC7807. A RFC7807 define e utiliza principalmente os seguintes elementos.
type: URI que identifica o tipo de erro. Geralmente é um documento que descreve o erro.title: Uma descrição curta sobre qual erro ocorreu.status: Idêntico ao HTTP Status Code.detail: Uma explicação detalhada do erro, legível por seres humanos.instance: URI em que o erro ocorreu. Por exemplo, se o erro ocorreu emGET /user/info, o valor será/user/info.extensions: Elemento secundário para descrever o erro, composto como um objeto JSON.- Por exemplo, no caso de
BadRequest, pode conter a entrada do usuário. - Ou, no caso de
TooManyRequest, pode conter o momento da requisição mais recente.
- Por exemplo, no caso de
Para utilizar isso de forma simples, criamos um novo arquivo no pacote httperror, no mesmo local de HttpError, definimos a estrutura RFC7807Error e permitimos sua criação por meio do padrão de encadeamento 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 { ... }
O "about:blank" em Type é o valor padrão, que significa uma página inexistente. Abaixo está um exemplo de criaçã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")
Com um encadeamento simples de métodos, é possível criar mensagens de erro estruturadas para o usuário. Além disso, para utilizar o roteador centralizado apresentado anteriormente, podemos oferecer o seguinte 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}
Utilizando isso diretamente para modificar o exemplo acima, obtemos o 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 tratar erros com um roteador centralizado desta forma, é possível reduzir o esforço de verificar códigos de erro e de escrever mensagens de erro apropriadas a cada vez. Além disso, ao utilizar a RFC7807 para fornecer mensagens de erro estruturadas, pode-se ajudar o cliente a compreender e tratar os erros. Por meio desses métodos, o tratamento de erros em APIs HTTP escritas em Go pode se tornar mais simples e consistente.
O código deste artigo pode ser consultado no repositório gosuda/httpwrap.