Connectly
Engenharia2024-12-05

Erros, erros por todo lado: como centralizamos e estruturamos o tratamento de erros em Go

Por Oliver Nguyen

Erros, erros por todo lado: como centralizamos e estruturamos o tratamento de erros em Go

Tratar erros em Go é simples e flexível -- mas sem estrutura!

Era pra ser simples, certo? Só retornar um error, embrulhado com uma mensagem, e seguir em frente. Bem, essa simplicidade rapidamente se torna caótica conforme nosso codebase cresce, com mais pacotes, mais desenvolvedores e mais "correções rápidas" que ficam ali para sempre. Com o tempo, os logs ficam cheios de "failed to do this" e "unexpected that", e ninguém sabe se é culpa do usuário, culpa do servidor, código com bug, ou apenas um desalinhamento dos astros!

Erros são criados com mensagens inconsistentes. Cada pacote tem seu próprio conjunto de estilos, constantes ou tipos de erro customizados. Códigos de erro são adicionados de forma arbitrária. Não há uma forma fácil de saber quais erros podem ser retornados por qual função sem investigar sua implementação!

Então, encarei o desafio de criar um novo framework de erros. Decidimos seguir com um sistema estruturado e centralizado, usando códigos com namespace para tornar os erros significativos, rastreáveis e -- o mais importante -- nos dar paz de espírito!

Esta é a história de como começamos com uma abordagem simples de tratamento de erros, ficamos completamente frustrados conforme os problemas cresciam e, por fim, construímos nosso próprio framework de erros. As decisões de design, como ele é implementado, as lições aprendidas e por que ele transformou nossa forma de gerenciar erros. Espero que isso traga algumas ideias para você também!

O post também foi publicado em olivernguyen.io.

Erros em Go são apenas valores

Go tem uma forma direta de tratar erros: erros são apenas valores. Um erro é apenas um valor que implementa a interface error, com um único método Error() string. Em vez de lançar uma exceção e interromper o fluxo de execução atual, funções em Go retornam um valor de erro junto com outros resultados. Quem chama a função pode então decidir como tratá-lo: verificar seu valor para tomar uma decisão, embrulhá-lo com novas mensagens e contexto, ou simplesmente retornar o erro, deixando a lógica de tratamento para quem chamou a função anteriormente.

Podemos transformar qualquer tipo em um erro adicionando o método Error() string a ele. Essa flexibilidade permite que cada pacote defina sua própria estratégia de tratamento de erros e escolha o que funciona melhor para ele. Isso também se integra bem com a filosofia de composabilidade do Go, facilitando embrulhar, estender ou customizar erros conforme necessário.

Todo pacote precisa lidar com erros

A prática comum é retornar um valor de erro que implementa a interface error e deixar quem chamou decidir o que fazer a seguir. Aqui está um exemplo típico:

func loadCredentials() (Credentials, error) {
    data, err := os.ReadFile("cred.json")
    if errors.Is(err, os.ErrNotExist) {
        return nil, fmt.Errorf("file not found: %w", err)
    }
    if err != nil {
        return nil, fmt.Errorf("failed to read file: %w", err)
    }
    cred, err := verifyCredentials(cred);
    if err != nil {
        return nil, fmt.Errorf("invalid credentials: %w", err)
    }
    return cred, nil
}
type xError struct {
    msg message,
    stack: callers(),
}

func Newf(msg string, args ...any) error {
    return &xError{  
        msg:   fmt.Sprintf(msg, args...),  
        stack: callers(),  // 👈 stacktrace
    }
}
func NewValuef(msg string, args ...any) error {
    return fmt.Errorf(msg, args...)  // 👈 no stacktrace
}
func Wrapf(err error, msg string, args ...any) error {
    if err == nil { return nil }
    stack := getStack(err)
    if stack == nil { stack = callers() }
    return &xError{
        msg:   fmt.Sprintf(msg, args...),
        stack: stack,
    }
}
package database

var ErrNotFound = errors.NewValue("record not found")
var ErrMultipleFound = errors.NewValue("multiple records found")
var ErrTimeout = errors.NewValue("request timeout")
package profile

var ErrUserNotFound = errors.NewValue("user not found")
var ErrBusinessNotFound = errors.NewValue("business not found")
var ErrContextCancel = errors.NewValue("context canceled")

Verificando erros com errors.Is() e embrulhando com contexto adicional

res, err := repo.QueryUser(ctx, req)
switch {
    case err == nil:
        // continue
    case errors.Is(database.NotFound):
        return nil, errors.Wrapf(ErrUserNotFound, "user not found (id=%v)", req.UserID)
    default:
        return nil, errors.Wrapf(ctx, "failed to query user (id=%v)", req.UserID)
}

Isso ajudou a propagar erros com mais detalhes, mas frequentemente resultava em verbosidade, duplicação e menos clareza nos logs:

internal server error: failed to query user: user not found (id=52a0a433-3922-48bd-a7ac-35dd8972dfe5): record not found: not found

Definindo erros externos com Protobuf

Para APIs voltadas ao público externo, adotamos um modelo de erro baseado em Protobuf, inspirado na Graph API da Meta:

message Error {
    string message = 1;
    ErrorType type = 2;
    ErrorCode code = 3;
    string user_title   = 4;
    string user_message = 5;
    string trace_id     = 6;
    
    map<string, string> details = 7;
}
enum ErrorType {
    ERROR_TYPE_UNSPECIFIED = 1;
    ERROR_TYPE_AUTHENTICATION = 2;
    ERROR_TYPE_INVALID_REQUEST = 3;
    ERROR_TYPE_RATE_LIMIT = 4;
    ERROR_TYPE_BUSINESS_LIMIT = 5;
    ERROR_TYPE_WEBHOOK_DELIVERY = 6;
}
enum ErrorCode {
    ERROR_CODE_UNSPECIFIED = 1 [(error_type = UNSPECIFIED)];
    ERROR_CODE_UNAUTHENTICATED = 2 [(error_type = AUTHENTICATION)];
    ERROR_CODE_CAMPAIGN_NOT_FOUND = 3 [(error_type = NOT_FOUND)];
    ERROR_CODE_META_CHOSE_NOT_TO_DELIVER = 4 /* ... */;
    ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS = 5;
}

Essa abordagem ajudou a estruturar os erros, mas, com o tempo, tipos e códigos de erro foram adicionados sem um plano claro, gerando inconsistências e duplicação.

E os problemas cresceram com o tempo

Erros eram declarados em todo lugar

  • Cada pacote definia suas próprias constantes de erro, sem um sistema centralizado.
  • Constantes e mensagens estavam espalhadas pelo codebase, tornando pouco claro quais erros uma função poderia retornar -- ugh, é gorm.ErrRecordNotFound ou user.ErrNotFound ou os dois?

O embrulho aleatório de erros levava a logs inconsistentes e arbitrários

  • Muitas funções embrulhavam erros com mensagens arbitrárias e inconsistentes, sem declarar seus próprios tipos de erro.
  • Os logs eram verbosos, redundantes e difíceis de buscar ou monitorar.
  • As mensagens de erro eram genéricas e muitas vezes não explicavam o que deu errado ou como aconteceu. Também eram frágeis e propensas a mudanças não percebidas.
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled

A falta de padronização levava a um tratamento de erros inadequado

  • Cada pacote tratava erros de forma diferente, dificultando saber se uma função retornava, embrulhava ou transformava erros.
  • O contexto frequentemente se perdia conforme os erros se propagavam.
  • As camadas superiores recebiam vagos 500 Internal Server Errors sem causas raiz claras.

A falta de categorização tornava o monitoramento impossível

  • Os erros não eram classificados por severidade ou comportamento: um erro context.Canceled pode ser um comportamento normal quando o usuário fecha a aba do navegador, mas é importante saber se a requisição foi cancelada porque aquela consulta estava lenta de forma aleatória.
  • Problemas importantes ficavam escondidos sob logs ruidosos, dificultando sua identificação.
  • Sem categorização, era impossível monitorar frequência, severidade ou impacto dos erros de forma eficaz.

É hora de centralizar o tratamento de erros

Voltando ao quadro branco

Para enfrentar os desafios crescentes, decidimos construir uma estratégia de erros melhor, em torno da ideia central de códigos de erro centralizados e estruturados.

  • Erros declarados em todo lugar → Centralizar a declaração de erros em um único lugar, para melhor organização e rastreabilidade.
  • Logs inconsistentes e arbitrários → Códigos de erro estruturados, com formatação clara e consistente.
  • Tratamento de erros inadequado → Padronizar a criação e verificação de erros no novo tipo Error, com um conjunto abrangente de helpers.
  • Falta de categorização → Categorizar os códigos de erro com tags, para monitoramento eficaz por meio de logs e métricas.

Decisões de design

Todos os códigos de erro são definidos em um local centralizado, com estrutura de namespace.

Usar namespaces para criar códigos de erro claros, significativos e extensíveis. Exemplo:

  • PRFL.USR.NOT_FOUND para "Usuário não encontrado."
  • FLD.NOT_FOUND para "Documento de fluxo não encontrado."
  • Ambos podem compartilhar um código base subjacente DEPS.PG.NOT_FOUND, significando "Registro não encontrado no PostgreSQL."

Cada camada de serviço ou biblioteca deve retornar apenas seus próprios códigos de namespace.

  • Cada camada de serviço, repositório ou biblioteca declara seu próprio conjunto de códigos de erro.
  • Quando uma camada recebe um erro de uma dependência, ela deve embrulhá-lo com seu próprio código de namespace antes de retorná-lo.
  • Por exemplo: ao receber um erro gorm.ErrRecordNotFound de uma dependência, o pacote "database" deve embrulhá-lo como DEPS.PG.NOT_FOUND. Depois, o serviço "profile/user" deve embrulhá-lo novamente como PRFL.USR.NOT_FOUND.

Todos os erros devem implementar a interface Error.

  • Isso cria uma fronteira clara entre erros de bibliotecas de terceiros (error) e nossos Errors internos.
  • Isso também ajuda no progresso da migração, para separar pacotes já migrados dos ainda não migrados.

Um erro pode embrulhar um ou vários erros. Juntos, eles formam uma árvore.

[FLD.INVALID_ARGUMENT] invalid argument
 → [TPL.INVALID_PARAMS] invalid input params
  1. [TPL.PARAM.EMPTY] name can not be empty
  2. [TPL.PARAM.MALFORM] invalid format for param[2]

Sempre exigir context.Context. É possível anexar contexto ao erro.

  • Muitas vezes vimos logs com erros isolados, sem contexto, sem trace_id, e sem ideia de onde vieram.
  • É possível anexar pares de chave/valor adicionais aos erros, que podem ser usados em logs ou monitoramento.

Quando erros são enviados através de uma fronteira de serviço, apenas o código de erro do nível mais alto é exposto.

  • Quem chama não precisa ver os detalhes internos de implementação daquele serviço.

Para erros externos, continuar usando o atual Protobuf ErrorCode e ErrorType.

  • Isso garante compatibilidade retroativa, para que nossos clientes não precisem reescrever seu código.

Mapear automaticamente os códigos de erro de namespace para códigos Protobuf, códigos de status HTTP e tags.

  • Os engenheiros definem o mapeamento em um local centralizado, e o framework mapeia cada código de erro para o ErrorCode, ErrorType, status gRPC, status HTTP e tags correspondentes, para logging/métricas.
  • Isso garante consistência e reduz duplicação.

O framework de erros com namespace

Pacotes e tipos principais

Existem alguns pacotes principais que formam a base do nosso novo framework de tratamento de erros.

connectly.ai/go/pkgs/

  • errors: o pacote principal que define o tipo Error e os códigos.
  • errors/api: para enviar erros ao front-end ou a APIs externas.
  • errors/E: pacote helper feito para ser usado com dot import.
  • testing: utilitários de teste para trabalhar com erros de namespace.

Error e Code

A interface Error é uma extensão da interface error padrão, com métodos adicionais para retornar um Code. Um Code é implementado como um uint16.

package errors // import "connectly.ai/go/pkgs/errors"

type Error interface {
    error
    Code() Code
}
type Code struct {
    code uint16
}
type CodeI interface {
    CodeDesc() CodeDesc
}
type GroupI interface { /* ... */ }
type CodeDesc struct { /* ... */ }

O pacote errors/E exporta todos os códigos de erro e tipos comuns

package E // import "connectly.ai/go/pkgs/errors/E"

import "connectly.ai/go/pkgs/errors"

type Error = errors.Error

var (
    DEPS = errors.DEPS
    PRFL = errors.PRFL
)

func MapError(ctx context.Context, err error) errors.Mapper { /* ... */ }    
func IsErrorCode(err error, codes ...errors.CodeI) { /* ... */ }
func IsErrorGroup(err error, groups ...errors.GroupI) { /* ... */ }

Exemplo de uso

Exemplos de códigos de erro:

// dependencies → postgres
DEPS.PG.NOT_FOUND
DEPS.PG.UNEXPECTED

// sdk → hash
SDK.HASH.UNEXPECTED

// profile → user
PRFL.USR.NOT_FOUND
PFRL.USR.UNKNOWN

// profile → user → repository
PRFL.USR.REPO.NOT_FOUND
PRFL.USR.REPO.UNKNOWN

// profile → auth
PRFL.AUTH.UNAUTHENTICATED
PRFL.AUTH.UNKNOWN
PRFL.AUTH.UNEXPECTED

Pacote database:

package database // import "connectly.ai/go/pkgs/database"

import "gorm.io/gorm"
import . "connectly.ai/go/pkgs/errors/E"

type DB struct { gorm: gorm.DB }

func (d *DB) Exec(ctx context.Context, sql string, params ...any) *DB {
    tx := d.gorm.WithContext(ctx).Exec(sql, params...)
    return wrapTx(tx)
}
func (x *DB) Error(msgArgs ...any) Error {
    return wrapError(tx.Error())  // 👈 convert gorm error to 'Error'
}
func (x *DB) SingleRowError(msgArgs ...any) Error {
    if err := x.Error(); err != nil { return err }
    switch {
    case x.RowsAffected == 1: return nil
    case x.RowsAffected == 0:
        return DEPS.PG.NOT_FOUND.CallerSkip(1).
            New(x.Context(), formatMsgArgs(msgArgs))
    default:
        return DEPS.PG.UNEXPECTED.CallerSkip(1).
            New(x.Context(), formatMsgArgs(msgArgs))
    }
}

Pacote pb/services/profile:

package profile // import "connectly.ai/pb/services/profile"

// these types are generated from services/profile.proto
type QueryUserRequest struct {
    BusinessId string
    UserId     string
}
type LoginRequest struct {
    Username string
    Password string
}

Pacote service/profile:

package profile

import uuid "github.com/google/uuid"
import . "connectly.ai/go/pkgs/errors/E"
import l "connectly.ai/go/pkgs/logging/l"
import profilepb "connectly.ai/pb/services/profile"

// repository requests
type QueryUserByUsernameRequest struct {
    Username string
}

// repository layer → query user
func (r *UserRepository) QueryUserByUsernameAuth(
    ctx context.Context, req *QueryUserByUsernameRequest,
) (*User, Error) {
    if req.Username == "" {
        return PRFL.USR.REPO.INVALID_ARGUMENT.New(ctx, "empty request")
    }

    var user User
    sqlQuery := `SELECT * FROM "user" WHERE username = ? LIMIT 1`
    tx := r.db.Exec(ctx, sqlQuery, req.Username).Scan(&user)     
    
    err := tx.SingleRowError()
    switch {
    case err == nil:
        return &user, nil
        
    case IsErrorCode(DEPS.PG.NOT_FOUND):
        return PRFL.USR.REPO.USER_NOT_FOUND.
            With(l.String("username", req.Username))
            Wrap(ctx, "user not found")
        
    default:
        return PRFL.USR.REPO.UNKNOWN.
            Wrap(ctx, "failed to query user")
    }
}

// user service layer → query user
func (u *UserService) QueryUser(
    ctx context.Context, req *profilepb.QueryUserRequest,
) (*profilepb.QueryUserResponse, Error) {
    // ...
    rr := QueryUserByUsernameRequest{ Username: req.Username }
    err := u.repo.QueryUserByUsername(ctx, rr)
    if err != nil {
        return nil, MapError(ctx, err).
            Map(PRFL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, 
                "the user %q cannot be found", req.UserName,
                api.UserTitle("User Not Found"),
                api.UserMsg("The requested user id %q can not be found", req.UserId)).    
            KeepGroup(PRFL.USR).    
            Default(PRFL.USR.UNKNOWN, "failed to query user")
    }
    // ...
    return resp, nil
}

// auth service layer → login user
func (a *AuthService) Login(
    ctx context.Context, req *profilepb.LoginRequest,
) (*profilepb.LoginResponse, *profilepb.LoginResponse, Error) {    

    vl := PRFL.AUTH.INVALID_ARGUMENT.WithMsg("invalid request")
    vl.Vl(req.Username != "", "no username", api.Detail("username is required"))
    vl.Vl(req.Password != "", "no password", api.Detail("password is required"))
    if err := vl.ToError(ctx); err != nil {
        return err
    }

    hashpwd, err := hash.Hash(req.Password)
    if err != nil {
        return PRFL.AUTH.UNEXPECTED.Wrap(ctx, err, "failed to calc hash")    
    }

    usrReq := profilepb.QueryUserByUsernameRequest{/*...*/}
    usrRes, err := a.userServiceClient.QueryUserByUsername(ctx, usrReq)
    if err != nil {
        return nil, MapError(ctx, err).
            Map(PRFL.USR.NOT_FOUND, PRFL.AUTH.UNAUTHENTICATED, "unauthenticated").
            Default(PRFL.AUTH.UNKNOWN, "failed to query by username")
    }
    // ...
}

Bem, tem uma porção de funções e conceitos novos no código acima. Vamos passar por eles passo a passo.

Criando e embrulhando erros

Primeiro, importe o pacote errors/E usando dot import

Isso vai permitir que você use diretamente tipos comuns como Error em vez de errors.Error, e acesse os códigos por PRFL.USR.NOT_FOUND em vez de errors.PRFL.USR.NOT_FOUND.

import . "connectly.ai/go/pkgs/errors/E"

Crie novos erros usando CODE.New()

Suponha que você receba uma requisição inválida; você pode criar um novo erro assim:

err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
  • PRFL.USR.INVALID_ARGUMENT é um Code.
  • Um Code expõe métodos como New() ou Wrap() para criar um novo erro.
  • A função New() recebe context.Context como primeiro argumento, seguido de mensagem e argumentos opcionais.

Imprima com fmt.Print(err):

[PRFL.USR.INVALID_ARGUMENT] invalid request

ou com fmt.Printf("%+v") para ver mais detalhes:

[PRFL.USR.INVALID_ARGUMENT] invalid request

connectly.ai/go/services/profile.(*UserService).QueryUser
    /usr/i/src/go/services/profile/user.go:1234
connectly.ai/go/services/profile.(*UserRepository).QueryUser
    /usr/i/src/go/services/profile/repo/user.go:2341

Embrulhe um erro dentro de um novo erro usando CODE.Wrap()

dbErr := DEPS.PG.NOT_FOUND.Wrap(ctx, gorm.ErrRecordNotFound, "not found")
usrErr := PRFL.USR.NOT_FOUND.Wrap(ctx, dbErr, "user not found")

vai produzir esta saída com fmt.Print(usrErr):

[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found

ou com fmt.Printf("%+v", usrErr)

[PRFL.USR.NOT_FOUND] user not found
  → [DEPS.PG.NOT_FOUND] not found
      → record not found

connectly.ai/go/services/profile.(*UserService).QueryUser
    /usr/i/src/go/services/profile/user.go:1234

O stacktrace vai vir do Error mais interno. Se você estiver escrevendo uma função helper, pode usar CallerSkip(skip) para pular frames:

func mapUserError(ctx context.Context, err error) Error {
    switch {
    case IsErrorCode(err, DEPS.PG.NOT_FOUND):
        return PRFL.USR.NOT_FOUND.CallerSkip(1).Wrap(ctx, err, "...")
    default:
        return PRFL.USR.UNKNOWN.CallerSkip(1).Wrap(ctx, err, "...")
    }
}

Adicionando contexto aos erros

Adicione contexto a um erro usando With()

  • Você pode adicionar pares de chave/valor adicionais aos erros com .With(l.String(...)).
  • logging/l é um pacote helper que exporta funções auxiliares para logging.
  • l.String("flag", flag) retorna um Tag{String: flag}, e l.UUID("user_id, userID) retorna Tag{Stringer: userID}.
import l "connectly.ai/go/pkgs/logging/l"

usrErr := PRFL.USR.NOT_FOUND.
    With(l.UUID("user_id", req.UserID), l.String("flag", flag)).
    Wrap(ctx, dbErr, "user not found")

As tags podem ser exibidas com fmt.Printf("%+v", usrErr):

[PRFL.USR.NOT_FOUND] user not found
{"user_id": "81febc07-5c06-4e01-8f9d-995bdc2e0a9a", "flag": "ABRW"}
  → [DEPS.PG.NOT_FOUND] not found
    {"a number": 42}
      → record not found

Adicione contexto aos erros diretamente dentro de New(), Wrap(), ou MapError():

Aproveitando a função l.String() e sua família, New() e funções similares conseguem detectar de forma inteligente as tags entre os argumentos de formatação. Não é preciso introduzir funções diferentes.

err := INF.HEALTH.NOT_READY.New(ctx, 
    "service %q is not ready (retried %v times)", 
    req.ServiceName, 
    l.String("flag", flag)
    countRetries,
    l.Number("count", countRetries),
)

vai produzir a saída:

[INF.HEALTH.NOT_READY] service "magic" is not ready (retried 2 times)    
{"flag": "ABRW", "count": 2}

Tipos diferentes: Error0, VlError, ApiError

Atualmente, existem 3 tipos que implementam as interfaces Error. Você pode adicionar mais tipos, se necessário. Cada um pode ter uma estrutura diferente, com métodos customizados para necessidades específicas.

Error é uma extensão da interface padrão error do Go

type Error interface {
    error
    Code()
    Message()
    Fields() []tags.Field
    StackTrace() stacktrace.StackTrace

    _base() *base // a private method
}

Ele contém um método privado para garantir que não implementemos acidentalmente novos tipos Error fora do pacote errors. Talvez (ou não) removamos essa restrição no futuro, quando tivermos mais experiência com padrões de uso.

Por que não simplesmente usar a interface padrão error e fazer type assertion?

Porque queremos separar erros de terceiros dos nossos erros internos. Todas as camadas e pacotes no nosso código interno devem sempre retornar Error. Assim, sabemos com segurança quando precisamos converter erros de terceiros, e quando só precisamos lidar com nossos códigos de erro internos.

Isso também cria uma fronteira entre pacotes já migrados e pacotes ainda não migrados. Voltando à realidade, não podemos simplesmente declarar um novo tipo, agitar uma varinha mágica, sussurrar um feitiço -- ou um prompt -- e então milhões de linhas de código se converterem magicamente e funcionarem perfeitamente sem bugs! Não, esse futuro ainda não chegou. Pode chegar um dia, mas por agora, ainda precisamos migrar nossos pacotes um por um.

Error0 é o tipo Error padrão

A maioria dos códigos de erro vai produzir um valor Error0. Ele contém um base e um sub-erro opcional. Você pode usar NewX() para retornar uma struct concreta *Error0 em vez de uma interface Error, mas precisa ter cuidado.

type Error0 struct {
    base  
    err error
}

var errA:  Error  = DEPS.PG.NOT_FOUND.New (ctx, "not found")
var errB: *Error0 = DEPS.PG.NOT_FOUND.NewX(ctx, "not found")

base é a estrutura comum compartilhada por todas as implementações de Error para fornecer funcionalidade comum: Code(), Message(), StackTrace(), Fields(), e mais.

type base struct {
    code  Code
    msg   string
    kv    []tags.Field
    stack stacktrace.StackTrace
}

VlError é para erros de validação

Ele pode conter múltiplos sub-erros e fornece métodos convenientes para trabalhar com helpers de validação.

type VlError struct {
    base
    errs []error
}

Você pode criar um VlError de forma semelhante a outros Error:

err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")

Ou criar um VlBuilder, adicionar erros a ele, e depois convertê-lo em um VlError:

userID, err0 := parseUUID(req.UserId)
err1 := validatePassword(req.Password)

vl := PRFL.USR.INVALID_ARGUMENT.WithMsg("invalid request")
vl.Add(err0, err1)
vlErr := vl.ToError(ctx)

E incluir pares de chave/valor normalmente:

vl := PRFL.USR.INVALID_ARGUMENT.
    With(l.Bool("testingenv", true)).
    WithMsg("invalid request")
userID, err0 := parseUUID(req.UserId)
err1 := validatePassword(req.Password)

vl.Add(err0, err1)
vlErr := vl.ToError(ctx, l.String("user_id", req.UserId))

Usando fmt.Printf("%+v", vlErr), a saída será:

[PRFL.USR.INVALID_ARGUMENT] invalid request
{"testingenv": true, "user_id": "A1234567890"}

ApiError é um adaptador para migrar erros de API

Anteriormente, usávamos uma struct separada api.Error para retornar erros de API ao front-end e a clientes externos. Ela inclui ErrorType e ErrorCode, como mencionado antes.

package api
import errorpb "connectly.ai/pb/models/error"

// Deprecated
type Error struct {
    pbType errorpb.ErrorType
    pbCode errorpb.ErrorCode

    cause    error
    msg      string
    usrMsg   string
    usrTitle string
    // ...
}

Esse tipo agora está depreciado. Em vez disso, declaramos todo o mapeamento (ErrorType, ErrorCode, código gRPC, código HTTP) em um local centralizado, e os convertemos nas fronteiras correspondentes. Vou discutir a declaração de códigos na próxima seção.

Para fazer a migração para o novo framework de erros com namespace, adicionamos um namespace temporário ZZZ.API_TODO. Todo ErrorCode se torna um código ZZZ.API_TODO.

ZZZ.API_TODO.UNEXPECTED
ZZZ.API_TODO.INVALID_REQUEST
ZZZ.API_TODO.USERNAME_
ZZZ.API_TODO.META_CHOSE_NOT_TO_DELIVER
ZZZ.API_TODO.MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS

E o ApiError é criado como um adaptador. Todas as funções que anteriormente retornavam *api.Error foram alteradas para retornar Error (implementado por *ApiError) em vez disso.

package api
import . "connectly.ai/go/pkgs/errors/E"

// previous
func FailPreconditionf(err error, msg string, args ...any) *Error {
    return &Error{
        pbType: ERROR_TYPE_FAILED_PRECONDITION,
        pbCode: ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS,    
        cause: err,
        msg: fmt.Sprintf(msg, args...)
    }
} 

// current: this is deprecated, and serves and an adapter
func FailPreconditionf(err error, msg string, args ...any) *Error {
    ctx := context.TODO()
    return ZZZ.API_TODO.MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS.
        CallerSkip(1).   // correct the stacktrace by 1 frame
        Wrap(ctx, err, msg, args...)
}

Quando toda a migração estiver concluída, o uso anterior:

wabaErr := verifyWabaTemplateStatus(tpl)
apiErr := api.FailPreconditionf(wabaErr, "template cannot be edited").
    WithErrorCode(ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS).    
    WithUserMsg("According to WhatsApp, the message template can be only edited once in 24 hours. Consider creating a new message template instead.").
    ErrorOrNil()

deveria se tornar:

CPG.TPL.EDIT_ONCE_IN_24_HOURS.Wrap(
    wabaErr, "template cannot be edited",
    api.UserMsg("According to WhatsApp, the message template can be only edited once in 24 hours. Consider creating a new message template instead."))

Note que o ErrorCode é derivado implicitamente do código de namespace interno. Não é preciso atribuí-lo explicitamente todas as vezes. Mas como declarar a relação entre os códigos? Isso será explicado na próxima seção.

Declarando novos códigos de erro

Neste ponto, você já sabe como criar novos erros a partir de códigos existentes. É hora de explicar sobre os códigos e como adicionar um novo.

Um Code é implementado como um valor uint16, que tem uma representação em string correspondente.

type Code struct { code: uint16 }

fmt.Printf("%q", DEPS.PG.NOT_FOUND)
// "DEPS.PG.NOT_FOUND"

Para armazenar essas strings, existe um array com todos os CodeDesc disponíveis:

const MaxCode = 321 // 👈 this value is generated
var   allCodes [MaxCode]CodeDesc

type CodeDesc {
    c    int     // 42
    code string  // DEPS.PG.NOT_FOUND
    api APICodeDesc
}
type APICodeDesc {
    ErrorType   errorpb.ErrorType
    ErrorCode   errorpb.ErrorCode
    HttpCode    int
    DefMessage  string
    UserMessage string
    UserTitle   string
}

Veja como os códigos são declarados:

var DEPS deps  // dependencies
var PRFL prfl  // profile
var FLD  fld   // flow document

type deps struct {
    PG pg      // postgres
    RD rd      // redis
}
// tag:postgres
type pg struct {
    NOT_FOUND   Code0 // record not found
    CONFLICT    Code0 // record already exist
    MALFORM_SQL Code0
}
// tag:profile
type PRFL struct {
    REPO prfl_repo
    USR  usr
    AUTH auth
}
// tag:profile
type prfl_repo struct {
    NOT_FOUND        Code0  // internal error code
    INVALID_ARGUMENT VlCode // internal error code
}
// tag:user
type usr struct {
    NOT_FOUND        Code0  `api-code:"USER_NOT_FOUND"`
    INVALID_ARGUMENT VlCode `api-code:"INVALID_ARGUMENT"`
    DISABlED_ACCOUNT Code0  `api-code:"DISABLED_ACCOUNT"`
}
// tag:auth
type auth struct {
    UNAUTHENTICATED   Code0 `api-code:"UNAUTHENTICATED"`
    PERMISSION_DENIED Code0 `api-code:"PERMISSION_DENIED"`
}

Depois de declarar novos códigos, você precisa executar o script de geração:

run gen-errors

O código gerado vai ter esta aparência:

// Code generated by error-codes. DO NOT EDIT.

func init() {
    // ...
    PRFL.AUTH.UNAUTHENTICATED = Code0{Code{code: 143}}  
    PRFL.AUTH.PERMISSION_DENIED = Code0{Code{code: 144}}
    
    // ...
    allCodes[143] = CodeDesc{
        c: 143, code: "PRFL.AUTH.UNAUTHENTICATED",
        tags: []string{"auth", "profile"},  
        api: APICodeDesc{  
          ErrorType:      ERROR_TYPE_UNAUTHENTICATED,  
          ErrorCode:      ERROR_CODE_UNAUTHENTICATED,              
          HTTPCode:       401,  
          DefMessage:     "Unauthenticated error",        
          UserMessage:    "You are not authenticated.",   
          UserTitle:      "Unauthenticated error",        
       }))
}

Cada tipo Error tem um tipo Code correspondente

Já se perguntou como PRFL.USR.NOT_FOUND.New() cria um *Error0 enquanto PRFL.USR.INVALID_ARGUMENTS.New() cria um *VlError? É porque eles usam tipos de código diferentes.

E cada tipo Code retorna um tipo Error diferente, cada um podendo ter seus próprios métodos extras:

type Code0  struct { Code }
type VlCode struct { Code }

func (c Code0) New(/*...*/) Error {
    return &Error0{/*...*/}
}
func (c VlCode) New(/*...*/) Error {
    return &VlError{/*...*/}
}

// extra methods on VlCode to create VlBuilder
func (c VlCode) WithMsg(msg string, args ...any) *VlBuilder {/*...*/}    

type VlBuilder struct {
    code VlCode
    msg  string
    args []any
}
func (b *VlBuilder) ToError(/*...*/) Error {
    return &VlError{Code: code, /*...*/ }
}

Use api-code para marcar os códigos disponíveis para API externa

  • O código de erro com namespace deve ser usado internamente.
  • Para deixar um código disponível para ser retornado em uma API HTTP externa, você precisa marcá-lo com api-code. O valor é o errorpb.ErrorCode correspondente.
  • Se um código de erro não estiver marcado com api-code, ele é um código interno e será exibido como um Internal Server Error genérico.
  • Note que PRFL.USR.NOT_FOUND é um código externo, enquanto PRFL.USR.REPO.NOT_FOUND é um código interno.

Declare o mapeamento entre ErrorCode, ErrorType e códigos gRPC/HTTP no protobuf usando enum option:

// error/type.proto
ERROR_TYPE_PERMISSION_DENIED = 707 [(error_type_detail_option) = {  
    type: "PermissionDeniedError",  
    grpc_code: PERMISSION_DENIED,  
    http_code: 403,  // Forbidden  
    message: "permission denied",  
    user_title: "Permission denied",  
    user_message: "The caller does not have permission to execute the specified operation.",  
}];

// error/code.proto
ERROR_CODE_DISABlED_ACCOUNT = 70020 [(error_code_detail_option) = {
    error_type: ERROR_TYPE_DISABlED_ACCOUNT,
    grpc_code: PERMISSION_DENIED,
    http_code: 403,  // Forbidden
    message: "account is disabled",  
    user_title: "Account is disabled",  
    user_message: "Your account is disabled. Please contact support for more information.",  
}];

Códigos UNEXPECTED e UNKNOWN

Cada camada geralmente tem 2 códigos genéricos, UNEXPECTED e UNKNOWN. Eles servem a propósitos um pouco diferentes:

  • O código UNEXPECTED é usado para erros que nunca deveriam acontecer.
  • O código UNKNOWN é usado para erros que não são tratados explicitamente.

Mapeando erros para um novo código

Ao receber um erro retornado por uma função, você precisa tratá-lo: converter erros de terceiros em erros de namespace internos e mapear códigos de erro das camadas internas para as camadas externas.

Convertendo erros de terceiros em erros de namespace internos

Como você trata os erros depende do que o pacote de terceiros retorna e do que sua aplicação precisa. Por exemplo, ao tratar erros de banco de dados ou de API externa:

switch {
case errors.Is(err, sql.ErrNoRows):
    // map a database "no rows" error to an internal "not found" error   
    return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found")

case errors.Is(err, context.DeadlineExceeded):
    // map a context deadline exceeded error to a timeout error
    return nil, PRFL.USR.TIMEOUT.Wrap(ctx, err, "query timeout")

default:
    // wrap any other error as unknown
    return nil, PRFL.USR.UNKNOWN.Wrap(ctx, err, "unexpected error")
}

Usando helpers para erros de namespace internos

  • IsErrorCode(err, CODES...): verifica se o erro contém algum dos códigos especificados.
  • IsErrorGroup(err, GROUP): retorna true se o erro pertence ao grupo informado.

Padrão de uso típico:

user, err := queryUser(ctx, userReq)
switch {
case err == nil:
    // continue

case IsErrorCode(PRL.USR.REPO.NOT_FOUND):    
    // check for specific error code and convert to external code
    // and return as HTTP 400 Not Found
    return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found")

case IsGroup(PRL.USR):
    // errors belong to the PRFL.USR group are returned as is
    return nil, err

default:
    return nil, PRL.USR.UNKNOWN.Wrap(ctx, err, "failed to query user")   
}

MapError() para escrever o código de mapeamento com mais facilidade:

Como mapear códigos de erro é um padrão comum, existe um helper MapError() para escrever o código mais rápido. O código acima pode ser reescrito como:

user, err := queryUser(ctx, userReq)
if err != nil {
    return nil, MapError(ctx, err).
        Map(PRL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, "user not found").    
        KeepGroup(PRF.USR).
        Default(PRL.USR.UNKNOWN, "failed to query user")
}

Você pode formatar argumentos e adicionar pares de chave/valor normalmente:

return nil, MapError(ctx, err).
    Map(PRL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, 
        "user %v not found", username, 
        l.String("flag", flag)).    
    KeepGroup(PRF.USR).
    Default(PRL.USR.UNKNOWN, "failed to query user", 
        l.Any("retries", retryCount))

Testando com erros de namespace

Testes são fundamentais para qualquer codebase levado a sério. O framework fornece helpers especializados como o ΩxError() para facilitar a escrita e a verificação de condições de erro nos testes, de forma mais expressiva.

// 👉 return true if the error contains the message
ΩxError(err).Contains("not found")

// 👉 return true if the error does not contain the message
ΩxError(err).NOT().Contains("not found")

Existem muitos outros métodos, e você também pode encadeá-los:

ΩxError(err).
    MatchCode(DEPS.PG.NOT_FOUND).  // match any code in top or wrapped errors
    TopErrorMatchCode(PRFL.TPL.NOT_FOUND) // only match code from the top error
    MatchAPICode(API_CODE.WABA_TEMPLATE_NOTE_FOUND). // match errorpb.ErrorCode
    MatchExact("exact message to match")

Por que usar métodos em vez de Ω(err).To(testing.MatchCode())?

Porque métodos são mais fáceis de descobrir. Quando você se depara com dezenas de funções como testing.MatchValues(), é difícil saber quais vão funcionar com Errors e quais não vão. Com métodos, basta digitar um ponto ., e a sua IDE vai listar todos os métodos disponíveis, feitos especificamente para verificar Errors.

Migração

O framework é só metade da história. Escrever o código? Essa é a parte fácil. O verdadeiro desafio começa quando você precisa trazê-lo para um codebase enorme e vivo, onde dezenas de engenheiros fazem push de mudanças diariamente, os clientes esperam que tudo funcione perfeitamente e o sistema simplesmente não pode parar de rodar.

Migração vem com responsabilidade. É sobre dividir cuidadosamente pequenos pedaços de código, fazer pequenas mudanças por vez, quebrar uma pilha de testes no processo. Depois, inspecionar e corrigir manualmente um por um, integrar na branch principal, fazer deploy em produção, observar os logs e alertas. Repetir isso de novo e de novo...

Aqui estão algumas dicas de migração que aprendemos pelo caminho:

Comece com busca e substituição: comece substituindo os padrões antigos pelo novo framework. Corrija os problemas de compilação que surgirem desse processo.

Por exemplo, substitua todos os error deste pacote por Error.

type ProfileController interface {
    LoginUser(req *LoginRequest) (*LoginResponse, error)
    QueryUser(req *QueryUserRequest) (*QueryUserResponse, error)
}

O novo código vai ter esta aparência:

import . "connectly.ai/go/pkgs/errors"

type ProfileController interface {
    LoginUser(req *LoginRequest) (*LoginResponse, Error)
    QueryUser(req *QueryUserRequest) (*QueryUserResponse, Error)
}

Migre um pacote por vez: comece pelos pacotes de nível mais baixo e vá subindo. Assim, você garante que os pacotes de nível mais baixo estejam totalmente migrados antes de avançar para os de nível mais alto.

Adicione os testes unitários que faltam: se partes do codebase não tiverem testes, adicione-os. Se você não estiver confiante nas suas mudanças, adicione mais testes. Eles ajudam a garantir que suas mudanças não quebrem funcionalidades existentes.

Se seu pacote depende de chamar pacotes de nível mais alto: considere transformar as funções relacionadas em DEPRECATED e adicionar novas funções com o novo tipo Error.

Suponha que você esteja migrando o pacote database, que tem o método Transaction():

package database

func (db *DB) Transaction(ctx context.Context, 
    fn func(tx *gorm.DB) error) error {
        return db.gorm.Transaction(func(tx *gorm.DB) error {
            return fn(tx)
        })
}

E ele é usado no pacote do serviço de usuário:

err = s.DB(ctx).Transaction(func(tx *database.DB) error {
    user, usrErr := s.repo.CreateUser(ctx, tx, user)
    if usrErr != nil {
        return usrErr
    }
}

Como você está migrando o pacote database primeiro, deixando o pacote user e dezenas de outros pacotes como estão, a chamada s.repo.CreateUser() ainda retorna o tipo de erro antigo, enquanto o método Transaction() precisa retornar o novo tipo Error. Você pode transformar o método Transaction() em DEPRECATED e adicionar um novo método TransactionV2():

package database

// DEPRECATED: use TransactionV2 instead
func (db *DB) Transaction_DEPRECATED(ctx context.Context, 
    fn func(tx *gorm.DB) error) error {
        return db.gorm.Transaction(func(tx *gorm.DB) error {
            return fn(tx)
        })
}

func (db *DB) TransactionV2(ctx context.Context, 
    fn func(tx *gorm.DB) error) Error {
        err := db.gorm.Transaction(func(tx *gorm.DB) error {
            return fn(tx)
        })
	    return adaptToErrorV2(err)
}

Adicione novos códigos de erro conforme necessário: quando você encontrar um erro que não se encaixa nos existentes, adicione um novo código. Isso vai ajudar você a construir um conjunto abrangente de códigos de erro com o tempo. Códigos de outros pacotes sempre estão disponíveis como referência.

Conclusão

Tratamento de erros em Go pode parecer simples no início -- só retornar um error e seguir em frente. Mas, conforme nosso codebase crescia, essa simplicidade se transformou em uma bagunça de logs vagos, tratamento inconsistente e sessões infinitas de debugging.

Ao dar um passo atrás e repensar como tratamos erros, construímos um sistema que funciona a nosso favor, não contra nós. Códigos de namespace centralizados e estruturados nos dão clareza, enquanto ferramentas para mapear, embrulhar e testar erros facilitam nossa vida. Em vez de nadar em um mar de logs, agora temos erros significativos e rastreáveis, que nos dizem o que está errado e onde olhar.

Esse framework não é só sobre deixar nosso código mais limpo; é sobre economizar tempo, reduzir frustração e nos ajudar a nos preparar para o desconhecido. É apenas o começo de uma jornada -- ainda estamos descobrindo mais padrões -- mas o resultado é um sistema que, de alguma forma, traz paz de espírito ao tratamento de erros. Espero que isso possa gerar algumas ideias para os seus projetos também! 😊

Autor

Eu sou o Oliver Nguyen -- engenheiro de software na Connectly.ai. Gosto de aprender e de me tornar uma versão melhor de mim mesmo todos os dias. De vez em quando, crio novos projetos open source. Compartilho conhecimento e reflexões durante essa jornada.

O post também foi publicado em olivernguyen.io.