GoSuda

Lidando com erros HTTP de forma menos incômoda + RFC7807

By snowmerak
views ...

Visão Geral

Ao criar uma API HTTP na linguagem Go, o tratamento de erros é frequentemente a parte mais tediosa. 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 o número de APIs for pequeno, esta abordagem pode não ser particularmente inconveniente. No entanto, à medida que o número de APIs aumenta e a lógica interna se torna mais complexa, três aspectos tornam-se problemáticos:

  1. Retorno de códigos de erro apropriados.
  2. Grande volume de registros de log.
  3. Transmissão de mensagens de erro claras.

Discussão

Retorno de códigos de erro apropriados

Certamente, o primeiro ponto, o retorno de códigos de erro apropriados, é uma queixa pessoal. Desenvolvedores experientes provavelmente encontrarão e aplicarão os códigos corretos de forma consistente. No entanto, desenvolvedores menos experientes, incluindo eu, podem ter dificuldade em usar códigos de erro adequados de forma regular, especialmente à medida que a lógica se torna mais complexa e o número de ocorrências aumenta. Existem vários métodos para abordar isso, sendo o mais comum projetar o fluxo da lógica da API antecipadamente e, em seguida, escrever o código para retornar os erros apropriados. Faça isso

Contudo, isso não parece ser o método ideal para desenvolvedores humanos que contam com a assistência de uma IDE (ou Language Server). Além disso, considerando que a própria REST API aproveita ao máximo o significado dos códigos de erro, pode-se propor uma abordagem alternativa. Crie uma nova implementação da interface error chamada HttpError que armazene StatusCode e Message. Em seguida, forneça as seguintes funções auxiliares:

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

A função auxiliar BadRequest retornará um HttpError com o StatusCode definido como 400 e o Message definido para o valor fornecido como argumento. Além disso, funções auxiliares como NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired, entre outras, podem ser consultadas e adicionadas por meio da funcionalidade de autocompletar. Isso será mais rápido do que verificar o documento de design a cada vez e mais estável do que inserir códigos de erro numericamente repetidamente. http.StatusCode já tem tudo? Silêncio

Grande volume de registros de log

É natural que os logs sejam gerados quando ocorre um erro. Ao registrar se uma chamada de API foi bem-sucedida ou falhou, registrar logs desde o início até todos os pontos de saída esperados resulta em uma grande quantidade de código a ser escrita. Isso pode ser gerenciado centralmente ao encapsular o próprio manipulador.

A seguir, um exemplo de encapsulamento do 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 Router contém um chi.Router internamente, configurando-o para usar as funcionalidades do chi.Router diretamente. No método Get, é verificado se a estrutura HttpError retornada pela função auxiliar proposta anteriormente foi retornada. Em caso afirmativo, ela é retornada apropriadamente; caso contrário, se for um error genérico, é uniformemente transmitida para a função de callback de erro. Este callback é recebido através do construtor.

O código a seguir foi 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}

O que você acha? Se um HttpError for simplesmente retornado como uma função auxiliar, o escopo superior pode responder com o código de erro e a mensagem apropriados, e callbacks podem ser registrados para permitir que cada serviço de implementação registre logs apropriados. Além disso, se necessário, pode-se estender para permitir um registro detalhado usando RequestID, entre outros.

Transmissão de mensagens de erro claras

O RFC7807 serve como documento para este propósito. O RFC7807 geralmente define os seguintes elementos para uso:

  • type: Um URI que identifica o tipo de erro. Geralmente é um documento que descreve o erro.
  • title: Uma descrição de uma linha sobre qual é o erro.
  • status: Equivalente ao HTTP Status Code.
  • detail: Uma descrição detalhada e legível por humanos do erro.
  • instance: O URI onde o erro ocorreu. Por exemplo, se um erro ocorreu em GET /user/info, então /user/info seria o valor.
  • extensions: Elementos secundários para descrever o erro, estruturados como um JSON Object.
    • Por exemplo, em caso de BadRequest, pode incluir a entrada do usuário.
    • Ou, em caso de TooManyRequest, pode incluir o momento da solicitação mais recente.

Para facilitar o uso, crie um novo arquivo no pacote httperror, na mesma localização do HttpError, e crie a estrutura RFC7807Error, permitindo a criação por meio de um 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 valor "about:blank" para Type é o padrão. Ele indica uma página inexistente. Abaixo está um exemplo de criação de erro para uma solicitaçã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")

Um encadeamento de métodos simples permite a criação de mensagens de erro estruturadas para o usuário. Além disso, para utilizar o roteador centralizado escrito anteriormente, os seguintes métodos podem ser suportados.

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

Utilizando-o diretamente, o exemplo acima pode ser modificado da seguinte forma:

 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 utilizar um roteador centralizado para o tratamento de erros, a carga de verificar códigos de erro e escrever mensagens de erro apropriadas repetidamente pode ser reduzida. Além disso, ao aproveitar o RFC7807 para fornecer mensagens de erro estruturadas, os clientes podem ser auxiliados na compreensão e tratamento de erros. Este método permite que o tratamento de erros em APIs HTTP escritas em Go seja mais simples e consistente.

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