Por Oliver Nguyen

¡Manejar errores en Go es simple y flexible -- pero sin estructura!
Se supone que es simple, ¿verdad? Solo devuelve un error, envuelto con un mensaje, y sigue adelante. Bueno, esa simplicidad rápidamente se vuelve caótica a medida que nuestro codebase crece con más paquetes, más desarrolladores y más "arreglos rápidos" que se quedan ahí para siempre. Con el tiempo, los logs se llenan de "failed to do this" y "unexpected that", y nadie sabe si es culpa del usuario, del servidor, de código con bugs, o simplemente un desalineamiento de las estrellas.
Los errores se crean con mensajes inconsistentes. Cada paquete tiene su propio conjunto de estilos, constantes o tipos de error personalizados. Los códigos de error se agregan arbitrariamente. No hay forma fácil de saber qué errores puede devolver una función sin escarbar en su implementación.
Así que asumí el desafío de crear un nuevo framework de errores. Decidimos ir con un sistema estructurado y centralizado usando códigos de namespace para hacer los errores significativos, trazables y -- lo más importante -- ¡para darnos tranquilidad!
Esta es la historia de cómo empezamos con un enfoque simple de manejo de errores, nos frustramos profundamente a medida que los problemas crecían, y finalmente construimos nuestro propio framework de errores. Las decisiones de diseño, cómo se implementa, las lecciones aprendidas, y por qué transformó nuestro enfoque para gestionar errores. ¡Espero que también te traiga algunas ideas!
Esta publicación también está publicada en olivernguyen.io.
Go tiene una forma directa de manejar errores: los errores son solo valores. Un error es simplemente un valor que implementa la interfaz error con un único método Error() string. En lugar de lanzar una excepción e interrumpir el flujo de ejecución actual, las funciones de Go devuelven un valor de error junto con otros resultados. Quien llama puede entonces decidir cómo manejarlo: verificar su valor para tomar una decisión, envolverlo con nuevos mensajes y contexto, o simplemente devolver el error, dejando la lógica de manejo para quienes lo llamen más arriba.
Podemos convertir cualquier tipo en un error agregándole el método Error() string. Esta flexibilidad permite que cada paquete defina su propia estrategia de manejo de errores, y elija lo que mejor le funcione. Esto también se integra bien con la filosofía de composabilidad de Go, facilitando envolver, extender o personalizar errores según se necesite.
La práctica común es devolver un valor de error que implemente la interfaz error y dejar que quien llama decida qué hacer a continuación. Aquí hay un ejemplo 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 errores con errors.Is() y envolviendo con 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)
}
Esto ayudó a propagar errores con más detalle pero a menudo resultó en verbosidad, duplicación y menos claridad en los logs:
internal server error: failed to query user: user not found (id=52a0a433-3922-48bd-a7ac-35dd8972dfe5): record not found: not found
Definiendo errores externos con Protobuf
Para las APIs de cara al exterior, adoptamos un modelo de errores basado en Protobuf inspirado en la Graph API de 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;
}
Este enfoque ayudó a estructurar los errores, pero con el tiempo, se agregaron tipos y códigos de error sin un plan claro, generando inconsistencias y duplicación.
Los errores se declaraban en todas partes
El envoltorio aleatorio de errores generó logs inconsistentes y arbitrarios
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled
La falta de estandarización llevó a un manejo de errores inapropiado
La falta de categorización hizo imposible el monitoreo
Para abordar los desafíos crecientes, decidimos construir una mejor estrategia de errores en torno a la idea central de códigos de error centralizados y estructurados.
Todos los códigos de error se definen en un lugar centralizado con estructura de namespace.
Usa namespaces para crear códigos de error claros, significativos y extensibles. Ejemplo:
Cada capa de servicio o librería solo debe devolver sus propios códigos de namespace.
Todos los errores deben implementar la interfaz Error.
Un error puede envolver uno o varios errores. Juntos, forman un árbol.
[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]
Siempre requiere context.Context. Se puede adjuntar contexto al error.
Cuando los errores se envían a través de límites de servicio, solo se expone el código de error de nivel superior.
Para errores externos, seguir usando el ErrorCode y ErrorType actuales de Protobuf.
Mapeo automático de códigos de error de namespace a códigos de Protobuf, códigos de estado HTTP y etiquetas.
Hay unos pocos paquetes principales que forman la base de nuestro nuevo framework de manejo de errores.
connectly.ai/go/pkgs/
Error y Code
La interfaz Error es una extensión de la interfaz error estándar, con métodos adicionales para devolver un Code. Un Code se implementa como un 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 { /* ... */ }
El paquete errors/E exporta todos los códigos de error y tipos comunes
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) { /* ... */ }
Ejemplos de códigos de error:
// 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
Paquete 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))
}
}
Paquete 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
}
Paquete 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")
}
// ...
}
Bueno, hay muchas funciones y conceptos nuevos en el código de arriba. Repasémoslos paso a paso.
Primero, importa el paquete errors/E usando dot import
Esto te permitirá usar directamente tipos comunes como Error en lugar de errors.Error y acceder a los códigos con PRFL.USR.NOT_FOUND en lugar de errors.PRFL.USR.NOT_FOUND.
import . "connectly.ai/go/pkgs/errors/E"
Crea nuevos errores usando CODE.New()
Supongamos que recibes una solicitud inválida; puedes crear un nuevo error con:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
Imprímelo con fmt.Print(err):
[PRFL.USR.INVALID_ARGUMENT] invalid request
o con fmt.Printf("%+v") para ver más detalles:
[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
Envuelve un error dentro de un nuevo error 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")
producirá esta salida con fmt.Print(usrErr):
[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found
o con 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
El stacktrace vendrá del Error más interno. Si estás escribiendo una función helper, puedes usar CallerSkip(skip) para saltar 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, "...")
}
}
Agrega contexto a un error 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")
Las etiquetas se pueden mostrar con 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
Agrega contexto a los errores directamente dentro de New(), Wrap(), o MapError():
Aprovechando la función l.String() y su familia, New() y funciones similares pueden detectar inteligentemente etiquetas entre los argumentos de formato. No es necesario introducir funciones 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),
)
producirá:
[INF.HEALTH.NOT_READY] service "magic" is not ready (retried 2 times)
{"flag": "ABRW", "count": 2}
Actualmente, hay 3 tipos que implementan las interfaces Error. Puedes agregar más tipos si es necesario. Cada uno puede tener una estructura diferente, con métodos personalizados para necesidades específicas.
Error es una extensión de la interfaz estándar error de Go
type Error interface {
error
Code()
Message()
Fields() []tags.Field
StackTrace() stacktrace.StackTrace
_base() *base // a private method
}
Contiene un método privado para asegurar que no implementemos accidentalmente nuevos tipos Error fuera del paquete errors. Podríamos (o no) levantar esa restricción en el futuro cuando tengamos más experiencia con patrones de uso.
¿Por qué no simplemente usamos la interfaz estándar error y usamos type assertion?
Porque queremos separar los errores de terceros de nuestros errores internos. Todas las capas y paquetes en nuestro código interno siempre deben devolver Error. De esta manera podemos saber con seguridad cuándo tenemos que convertir errores de terceros, y cuándo solo necesitamos lidiar con nuestros códigos de error internos.
También crea un límite entre paquetes migrados y paquetes aún no migrados. Volviendo a la realidad, no podemos simplemente declarar un nuevo tipo, agitar una varita mágica, susurrar un hechizo -- o un prompt -- y que millones de líneas de código se conviertan mágicamente y funcionen sin problemas y sin bugs. No, ese futuro aún no ha llegado. Puede que llegue algún día, pero por ahora, todavía tenemos que migrar nuestros paquetes uno por uno.
Error0 es el tipo Error predeterminado
La mayoría de los códigos de error producirán un valor Error0. Contiene un base y un sub-error opcional. Puedes usar NewX() para devolver un struct *Error0 concreto en lugar de una interfaz Error, pero necesitas tener 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 es la estructura común compartida por todas las implementaciones de Error para proporcionar funcionalidad común: Code(), Message(), StackTrace(), Fields(), y más.
type base struct {
code Code
msg string
kv []tags.Field
stack stacktrace.StackTrace
}
VlError es para errores de validación
Puede contener múltiples sub-errores, y proporciona métodos convenientes para trabajar con helpers de validación.
type VlError struct {
base
errs []error
}
Puedes crear un VlError de forma similar a otros Error:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
O crear un VlBuilder, agregarle errores, y luego convertirlo en un 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 clave/valor como de costumbre:
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))
Usar fmt.Printf("%+v", vlErr) mostrará:
[PRFL.USR.INVALID_ARGUMENT] invalid request
{"testingenv": true, "user_id": "A1234567890"}
ApiError es un adaptador para migrar errores de API
Anteriormente, usábamos un struct api.Error separado para devolver errores de API al front-end y a clientes externos. Incluye ErrorType y ErrorCode como se mencionó 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
// ...
}
Este tipo ahora está obsoleto. En su lugar, declararemos todo el mapeo (ErrorType, ErrorCode, código gRPC, código HTTP) en un lugar centralizado, y los convertiremos en los límites correspondientes. Hablaré sobre la declaración de códigos en la siguiente sección.
Para hacer la migración al nuevo framework de errores con namespace, agregamos un namespace temporal ZZZ.API_TODO. Cada ErrorCode se convierte en un 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
Y ApiError se crea como un adaptador. Todas las funciones que antes devolvían *api.Error se cambiaron para devolver Error (implementado por *ApiError) en su lugar.
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...)
}
Cuando toda la migración esté hecha, el 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()
debería convertirse en:
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."))
Nota que el ErrorCode se deriva implícitamente del código de namespace interno. No es necesario asignarlo explícitamente cada vez. Pero ¿cómo se declara la relación entre códigos? Se explicará en la siguiente sección.
En este punto, ya sabes cómo crear nuevos errores a partir de códigos existentes. Es momento de explicar los códigos y cómo agregar uno nuevo.
Un Code se implementa como un valor uint16, que tiene una representación de string correspondiente.
type Code struct { code: uint16 }
fmt.Printf("%q", DEPS.PG.NOT_FOUND)
// "DEPS.PG.NOT_FOUND"
Para almacenar esos strings, hay un array de todos los CodeDesc disponibles:
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
}
Así es como se declaran los códigos:
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"`
}
Después de declarar nuevos códigos, necesitas ejecutar el script de generación:
run gen-errors
El código generado se verá así:
// 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 tiene un tipo Code correspondiente
¿Alguna vez te preguntaste cómo PRFL.USR.NOT_FOUND.New() crea un *Error0 mientras que PRFL.USR.INVALID_ARGUMENTS.New() crea un *VlError? Es porque usan diferentes tipos de código.
Y cada tipo Code devuelve un tipo Error diferente, cada uno puede tener sus propios métodos extra:
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, /*...*/ }
}
Usa api-code para marcar los códigos disponibles para la API externa
Declara el mapeo entre ErrorCode, ErrorType, y códigos gRPC/HTTP en 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 y UNKNOWN
Cada capa usualmente tiene 2 códigos genéricos, UNEXPECTED y UNKNOWN. Sirven propósitos ligeramente diferentes:
Cuando recibes un error devuelto por una función, necesitas manejarlo: convertir errores de terceros en errores de namespace internos y mapear códigos de error de capas internas a capas externas.
Convertir errores de terceros en errores de namespace internos
Cómo manejas los errores depende de: qué devuelve el paquete de terceros y qué necesita tu aplicación. Por ejemplo, al manejar errores de base de datos o 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 errores de namespace internos
Patrón 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 escribir código de mapeo más fácilmente:
Dado que mapear códigos de error es un patrón común, hay un helper MapError() para escribir código más rápido. El código de arriba se puede reescribir así:
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")
}
Puedes formatear argumentos y agregar pares clave/valor como de costumbre:
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))
Las pruebas son fundamentales para cualquier codebase serio. El framework proporciona helpers especializados como ΩxError() para facilitar y hacer más expresiva la escritura y aserción de condiciones de error en las pruebas.
// 👉 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")
Hay muchos más métodos, y también se pueden encadenar:
Ω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 qué usar métodos en lugar de Ω(err).To(testing.MatchCode())?
Porque los métodos son más fáciles de descubrir. Cuando te enfrentas a docenas de funciones como testing.MatchValues(), es difícil saber cuáles funcionarán con Errors y cuáles no. Con métodos, simplemente escribes un punto ., y tu IDE listará todos los métodos disponibles diseñados específicamente para hacer aserciones sobre Errors.
El framework es solo la mitad de la historia. ¿Escribir el código? Esa es la parte fácil. El verdadero desafío empieza cuando tienes que integrarlo en un codebase masivo y vivo, donde docenas de ingenieros suben cambios diariamente, los clientes esperan que todo funcione perfectamente, y el sistema simplemente no puede dejar de funcionar.
La migración viene con responsabilidad. Se trata de dividir cuidadosamente pequeños fragmentos de código, hacer pequeños cambios a la vez, romper un montón de pruebas en el proceso. Luego inspeccionarlas y arreglarlas manualmente una por una, fusionar a la rama principal, desplegar a producción, observar los logs y las alertas. Repetirlo una y otra vez...
Aquí hay algunos consejos de migración que aprendimos en el camino:
Empieza con buscar y reemplazar: Comienza reemplazando los patrones antiguos con el nuevo framework. Corrige los problemas de compilación que surjan de este proceso.
Por ejemplo, reemplaza todos los error en este paquete con Error.
type ProfileController interface {
LoginUser(req *LoginRequest) (*LoginResponse, error)
QueryUser(req *QueryUserRequest) (*QueryUserResponse, error)
}
El nuevo código se verá así:
import . "connectly.ai/go/pkgs/errors"
type ProfileController interface {
LoginUser(req *LoginRequest) (*LoginResponse, Error)
QueryUser(req *QueryUserRequest) (*QueryUserResponse, Error)
}
Migra un paquete a la vez: Empieza con los paquetes de nivel más bajo y avanza hacia arriba. De esta manera, puedes asegurarte de que los paquetes de nivel inferior estén completamente migrados antes de pasar a los de nivel superior.
Agrega las pruebas unitarias faltantes: Si partes del codebase carecen de pruebas, agrégalas. Si no tienes confianza en tus cambios, agrega más pruebas. Son útiles para asegurarte de que tus cambios no rompan la funcionalidad existente.
Si tu paquete depende de llamar a paquetes de nivel superior: Considera cambiar las funciones relacionadas a DEPRECATED y luego agregar nuevas funciones con el nuevo tipo Error.
Supongamos que estás migrando el paquete database, que tiene el 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)
})
}
Y se usa en el paquete del servicio de usuario:
err = s.DB(ctx).Transaction(func(tx *database.DB) error {
user, usrErr := s.repo.CreateUser(ctx, tx, user)
if usrErr != nil {
return usrErr
}
}
Dado que estás migrando primero el paquete database, dejando el de user y docenas de otros paquetes como están. La llamada s.repo.CreateUser() todavía devuelve el tipo de error antiguo mientras que el método Transaction() necesita devolver el nuevo tipo Error. Puedes cambiar el método Transaction() a DEPRECATED y agregar un nuevo 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)
}
Agrega nuevos códigos de error sobre la marcha: Cuando encuentres un error que no encaje en los existentes, agrega un nuevo código. Esto te ayudará a construir un conjunto completo de códigos de error con el tiempo. Los códigos de otros paquetes siempre están disponibles como referencia.
El manejo de errores en Go puede parecer simple al principio -- solo devuelve un error y sigue adelante. Pero a medida que nuestro codebase crecía, esa simplicidad se convirtió en un enredo de logs vagos, manejo inconsistente y sesiones de debugging interminables.
Al dar un paso atrás y repensar cómo manejamos los errores, construimos un sistema que trabaja para nosotros, no en contra nuestra. Los códigos de namespace centralizados y estructurados nos dan claridad, mientras que las herramientas para mapear, envolver y probar errores nos hacen la vida más fácil. En lugar de nadar en un mar de logs, ahora tenemos errores significativos y trazables que nos dicen qué está mal y dónde buscar.
Este framework no se trata solo de hacer nuestro código más limpio; se trata de ahorrar tiempo, reducir la frustración y ayudarnos a prepararnos para lo desconocido. Es solo el comienzo de un camino -- todavía estamos descubriendo más patrones -- pero el resultado es un sistema que de alguna manera puede traer tranquilidad al manejo de errores. ¡Esperamos que también pueda despertar algunas ideas para tus proyectos! 😊
Soy Oliver Nguyen -- ingeniero de software en Connectly.ai. Disfruto aprender y ver una mejor versión de mí mismo cada día. Ocasionalmente lanzo nuevos proyectos de código abierto. Comparto conocimiento y reflexiones durante mi camino.
Esta publicación también está publicada en olivernguyen.io.
©️ 2025 Todos los derechos reservados. Connectly Inc. Creado con ❤️, globalmente.