Por Oliver Nguyen

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.
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.
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.
Erros eram declarados em todo lugar
O embrulho aleatório de erros levava a logs inconsistentes e arbitrários
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
A falta de categorização tornava o monitoramento impossível
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.
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:
Cada camada de serviço ou biblioteca deve retornar apenas seus próprios códigos de namespace.
Todos os erros devem implementar a interface Error.
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.
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.
Para erros externos, continuar usando o atual Protobuf ErrorCode e ErrorType.
Mapear automaticamente os códigos de erro de namespace para códigos Protobuf, códigos de status HTTP e tags.
Existem alguns pacotes principais que formam a base do nosso novo framework de tratamento de erros.
connectly.ai/go/pkgs/
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) { /* ... */ }
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.
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")
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, "...")
}
}
Adicione contexto a um erro usando With()
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}
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.
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
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:
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
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))
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.
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.
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! 😊
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.
©️ 2025 Todos os direitos reservados. Connectly Inc. Desenvolvido com ❤️, globalmente.