La gestión de errores en Go es sencilla y flexible, ¡pero no tiene estructura!
Se supone que es simple, ¿no? Solo hay que devolver un error
, envuelto en un mensaje, y seguir adelante. Bueno, esa simplicidad rápidamente se vuelve caótica a medida que nuestra base de código crece con más paquetes, más desarrolladores y más "soluciones rápidas" que permanecen allí para siempre. Con el tiempo, los registros se llenan de "no se pudo hacer esto" y "no se esperaba eso", y nadie sabe si es culpa del usuario, del servidor, un código con errores o simplemente una desalineación 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 de manera arbitraria. ¡No hay una manera sencilla de saber qué errores pueden devolverse de qué función sin investigar su implementación!
Por eso, acepté el desafío de crear un nuevo marco de trabajo de errores. Decidimos optar por un sistema estructurado y centralizado que utilice códigos de espacio de nombres para que los errores sean significativos, rastreables y, lo más importante, ¡nos brinden tranquilidad!
Esta es la historia de cómo empezamos con un enfoque de gestión de errores simple, nos frustramos muchísimo a medida que los problemas crecían y finalmente creamos nuestro propio marco de trabajo de errores. Las decisiones de diseño, cómo se implementó, las lecciones aprendidas y por qué transformó nuestro enfoque de gestión de errores. ¡Espero que también te sirva de ideas!
Go tiene una forma sencilla de manejar los errores: los errores son simplemente 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 error
junto con otros resultados. El invocador puede decidir cómo manejarlo: verificar su valor para tomar una decisión, envolver con nuevos mensajes y contexto, o simplemente devolver el error, dejando la lógica de manejo para los invocadores principales.
Podemos convertir cualquier tipo en un error
si le añadimos el método Error() string
. Esta flexibilidad permite que cada paquete defina su propia estrategia de gestión de errores y elija la que mejor se adapte a sus necesidades. Esto también se integra bien con la filosofía de componibilidad de Go, lo que facilita la inclusión, la ampliación o la personalización de errores según sea necesario.
La práctica habitual es devolver un valor de error que implementa la interfaz error
y permite que el emisor decida qué hacer a continuación. A continuación, se muestra 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 }
Go proporciona un puñado de utilidades para trabajar con errores:
errors.New()
y fmt.Errorf()
para generar errores simples.fmt.Errorf()
y el verbo %w
.errors.Join()
fusiona varios errores en uno solo.errors.Is()
hace coincidir un error con un valor específico, errors.As()
hace coincidir un error con un tipo específico y errors.Unwrap()
recupera el error subyacente.
En la práctica, solemos ver estos patrones:
errors.New()
o fmt.Errorf()
.En los primeros días, al igual que muchos desarrolladores de Go, seguimos las prácticas comunes de Go y mantuvimos el manejo de errores al mínimo, pero funcional. Funcionó bastante bien durante un par de años.
Incluya stacktrace usando pkg/errors , un paquete popular en ese momento.
Exportar constantes o variables para errores específicos del paquete.
Utilice errors.Is()
para comprobar errores específicos.
Envuelva los errores con nuevos mensajes y contexto.
Para los errores de API, definimos tipos de error y códigos con enumeración Protobuf.
Incluyendo seguimiento de pila con pkg/errors
Usamos pkg/errors , un paquete de manejo de errores popular en ese momento, para incluir stacktrace en nuestros errores. Esto fue particularmente útil para la depuración, ya que nos permitió rastrear el origen de los errores en diferentes partes de la aplicación.
Para crear, encapsular y propagar errores con StackTrace, implementamos funciones como Newf()
, NewValuef()
y Wrapf()
. Aquí hay un ejemplo de nuestra implementación inicial:
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, } }
Exportación de variables de error
Cada paquete de nuestra base de código definió sus propias variables de error, a menudo con estilos inconsistentes.
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")
Comprobación de errores con errors.Is()
y envoltura 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 detalles, pero a menudo resultó en verbosidad, duplicación y menos claridad en los registros:
internal server error: failed to query user: user not found (id=52a0a433-3922-48bd-a7ac-35dd8972dfe5): record not found: not found
Definición de errores externos con Protobuf
Para las API externas, adoptamos un modelo de error basado en Protobuf inspirado en laGraph 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, lo que generó inconsistencias y duplicaciones.
Se declararon errores en todas partes.
gorm.ErrRecordNotFound
o user.ErrNotFound
o ambos?
El error aleatorio de envoltura generó registros inconsistentes y arbitrarios
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled
La falta de estandarización condujo a un manejo inadecuado de los errores
No existe categorización que haga imposible el seguimiento
context.Canceled
puede ser un comportamiento normal cuando el usuario cierra la pestaña del navegador, pero es importante si la solicitud se cancela porque esa consulta es aleatoriamente lenta.Para abordar los desafíos crecientes, decidimos construir una mejor estrategia de error en torno a la idea central de códigos de error centralizados y estructurados .
Error
con un conjunto integral de ayudantes.Todos los códigos de error se definen en un lugar centralizado con estructura de espacio de nombres.
Utilice espacios de nombres para crear códigos de error claros, significativos y ampliables. Ejemplo:
PRFL.USR.NOT_FOUND
para "Usuario no encontrado".FLD.NOT_FOUND
para "Documento de flujo no encontrado".DEPS.PG.NOT_FOUND
, que significa "Registro no encontrado en PostgreSQL".
Cada capa de servicio o biblioteca solo debe devolver sus propios códigos de espacio de nombres .
gorm.ErrRecordNotFound
de una dependencia, el paquete "database" debe encapsularlo como DEPS.PG.NOT_FOUND
. Luego, el servicio "profile/user" debe encapsularlo nuevamente como PRFL.USR.NOT_FOUND
.
Todos los errores deben implementar la interfaz Error
.
error
) y nuestros Error
internos.
Un error puede contener uno o varios errores, que 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
. Puede adjuntar contexto al error.
trace_id
y no tenemos idea de dónde provienen.
Cuando se envían errores a través del límite del servicio, solo se expone el código de error de nivel superior.
Para errores externos, siga utilizando el ErrorCode y ErrorType de Protobuf actuales.
Asigna automáticamente códigos de error de espacio de nombres a códigos Protobuf, códigos de estado HTTP y etiquetas.
ErrorCode
, ErrorType
, estado gRPC, estado HTTP y etiquetas para registro/métricas correspondientes.Hay algunos paquetes básicos que forman la base de nuestro nuevo marco de manejo de errores.
connectly.ai/go/pkgs/
errors
: el paquete principal que define el tipo Error
y los códigos.errors/api
: para enviar errores al front-end o a la API externa.errors/E
: Paquete auxiliar destinado a ser utilizado con dot import.testing
: Utilidades de prueba para trabajar con errores de espacio de nombres.
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 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) { /* ... */ }
Códigos de error de ejemplo:
// 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
database
de paquetes:
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 }
service/profile
del paquete:
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 anterior. Veámoslos paso a paso.
Primero, importe el paquete errors/E
usando dot import
Esto le permitirá utilizar directamente tipos comunes como Error
en lugar de errors.Error
y acceder a códigos mediante 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 recibe una solicitud no válida, puede crear un nuevo error de la siguiente manera:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
PRFL.USR.INVALID_ARGUMENT
es un Code
.Code
expone métodos como New()
o Wrap()
para crear un nuevo error.New()
recibe context.Context
como primer argumento, seguido del mensaje y argumentos opcionales.
Imprimelo 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
Envuelva 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 seguimiento de la pila procederá del Error
más interno. Si está escribiendo una función auxiliar, puede utilizar CallerSkip(skip)
para omitir fotogramas:
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, "...") } }
Agregar contexto a un error usando With()
.With(l.String(...))
.logging/l
es un paquete auxiliar para exportar funciones de sugar para el registro.l.String("flag", flag)
devuelve una Tag{String: flag}
y l.UUID("user_id, userID)
devuelve 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")
Las etiquetas se pueden generar 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
Agregue contexto a los errores directamente dentro de New()
, Wrap()
o MapError()
:
Aprovechando la función l.String()
y su familia, New()
y otras funciones similares pueden detectar etiquetas de forma inteligente 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), )
salida:
[INF.HEALTH.NOT_READY] service "magic" is not ready (retried 2 times) {"flag": "ABRW", "count": 2}
Error0
, VlError
, ApiError
Actualmente, existen 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 error
estándar 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 garantizar que no implementemos por accidente nuevos tipos Error
fuera del paquete errors
. Es posible que eliminemos (o no) esa restricción en el futuro cuando experimentemos con más patrones de uso.
¿Por qué no usamos simplemente la interfaz error
estándar y utilizamos la afirmación de tipo?
Porque queremos separar los errores de terceros de nuestros errores internos. Todas las capas y paquetes de nuestros códigos internos deben devolver siempre Error
. De esta forma, podemos saber con seguridad cuándo tenemos que convertir los errores de terceros y cuándo solo tenemos que ocuparnos de nuestros códigos de error internos.
También crea un límite entre los paquetes migrados y los que aún no se han migrado. Volviendo a la realidad, no podemos simplemente declarar un nuevo tipo, agitar una varita mágica, susurrar un mensaje de hechizo y, luego, ¡todos los millones de líneas de código se convierten mágicamente y funcionan sin problemas y sin errores! 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 de Error
predeterminado
La mayoría de los códigos de error producirán un valor Error0
. Este contiene una base
y un suberror opcional. Puedes usar NewX()
para devolver una estructura *Error0
concreta en lugar de una interfaz Error
, pero debes 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 Error
, para proporcionar una 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 suberrores y proporcionar métodos útiles para trabajar con ayudantes de validación.
type VlError struct { base errs []error }
Puedes crear un VlError
similar a otro Error
:
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
O bien crea un VlBuilder
, agrégale errores y luego conviértelo 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 incluya 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))
El uso de fmt.Printf("%+v", vlErr)
generará el siguiente resultado:
[PRFL.USR.INVALID_ARGUMENT] invalid request {"testingenv": true, "user_id": "A1234567890"}
ApiError
es un adaptador para migrar errores de API
Anteriormente, usábamos una estructura api.Error
independiente para devolver errores de API al front-end y a los clientes externos. Incluye ErrorType
como ErrorCode
como se mencionó anteriormente .
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 ya no se utiliza. En su lugar, declararemos todas las asignaciones ( ErrorType
, ErrorCode
, código gRPC, código HTTP) en un lugar centralizado y las convertiremos en los límites correspondientes. Hablaré sobre la declaración de código en la siguiente sección .
Para realizar la migración al nuevo marco de errores de espacio de nombres, agregamos un espacio de nombres 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 se crea ApiError
como adaptador. Todas las funciones que antes devolvían *api.Error
se cambiaron para que devuelvan Error
(implementado por *ApiError
).
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...) }
Una vez realizada toda la migración, 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."))
Tenga en cuenta que el ErrorCode
se deriva implícitamente del código del espacio de nombres interno. No es necesario asignarlo explícitamente cada vez. Pero, ¿cómo declarar la relación entre los 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 hora de explicar qué son los códigos y cómo agregar uno nuevo.
Un Code
se implementa como un valor uint16
, que tiene una presentación de cadena correspondiente.
type Code struct { code: uint16 } fmt.Printf("%q", DEPS.PG.NOT_FOUND) // "DEPS.PG.NOT_FOUND"
Para almacenar esas cadenas, hay una matriz de todos 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:usr 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, debes 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 se preguntó cómo PRFL.USR.NOT_FOUND.New()
crea un *Error0
y PRFL.USR.INVALID_ARGUMENTS.New()
crea un *VlError
? Es porque utilizan diferentes tipos de código.
Y cada tipo Code
devuelve un tipo Error
diferente, cada uno puede tener sus propios métodos adicionales:
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, /*...*/ } }
Utilice api-code
para marcar los códigos disponibles para API externas
El código de error del espacio de nombres debe usarse internamente.
Para que un código esté disponible para su devolución en una API HTTP externa, debe marcarlo con api-code
. El valor es el errorpb.ErrorCode
correspondiente.
Si un código de error no está marcado con api-code
, es un código interno y se mostrará como un Internal Server Error
genérico.
Tenga en cuenta que PRFL.USR.NOT_FOUND
es un código externo, mientras que PRFL.USR.REPO.NOT_FOUND
es un código interno.
Declare la asignación entre ErrorCode
, ErrorType
y códigos gRPC/HTTP en protobuf usando la opción de enumeración:
// 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 suele tener dos códigos genéricos UNEXPECTED
y UNKNOWN
. Tienen propósitos ligeramente diferentes:
UNEXPECTED
se utiliza para errores que nunca deberían ocurrir.UNKNOWN
se utiliza para errores que no se manejan explícitamente.Al recibir un error devuelto por una función, debe manejarlo: convertir los errores de terceros en errores de espacio de nombres internos y asignar códigos de error de capas internas a capas externas.
Convertir errores de terceros en errores de espacio de nombres internos
La forma en que se gestionan los errores depende de lo que devuelve el paquete de terceros y de lo que necesita la aplicación. Por ejemplo, al gestionar 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") }
Uso de ayudantes para errores de espacios de nombres internos
IsErrorCode(err, CODES...)
: Comprueba si el error contiene alguno de los códigos especificados.IsErrorGroup(err, GROUP)
: Devuelve verdadero si el error pertenece al grupo de entrada.
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 la asignación de códigos de error es un patrón común, existe un asistente MapError()
para agilizar la escritura del código. El código anterior se puede reescribir de la siguiente manera:
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") }
Puede 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))
Error
s Las pruebas son fundamentales para cualquier base de código seria. El marco proporciona ayudantes especializados como ΩxError()
para que escribir y confirmar condiciones de error en las pruebas sea más fácil y expresivo.
// 👉 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 puedes encadenarlos:
Ω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é utilizar 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 Error
s y cuáles no. Con los métodos, puedes simplemente escribir un punto .
, y tu IDE mostrará una lista de todos los métodos disponibles diseñados específicamente para confirmar Error
s.
El marco de trabajo es solo la mitad de la historia. ¿Escribir el código? Esa es la parte fácil. El verdadero desafío comienza cuando hay que incorporarlo a una base de código masiva y viva donde docenas de ingenieros están implementando cambios a diario, los clientes esperan que todo funcione perfectamente y el sistema simplemente no puede dejar de funcionar.
La migración conlleva responsabilidad. Se trata de dividir minuciosamente fragmentos de código, realizar cambios minúsculos a la vez, romper un montón de pruebas en el proceso. Luego, inspeccionarlos y corregirlos manualmente uno por uno, fusionarlos en la rama principal, implementarlos en producción, observar los registros y las alertas. Repetirlo una y otra vez...
Aquí hay algunos consejos para la migración que aprendimos a lo largo del camino:
Comience con la búsqueda y reemplazo: comience reemplazando los patrones antiguos con el nuevo marco. Corrija los problemas de compilación que surjan de este proceso.
Por ejemplo, reemplace todos 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) }
Migrar un paquete a la vez: comience con los paquetes de nivel más bajo y avance hacia arriba. De esta manera, puede asegurarse de que los paquetes de nivel inferior se migren por completo antes de pasar a los de nivel superior.
Agregue pruebas unitarias faltantes: si faltan pruebas en partes del código base, agréguelas. Si no está seguro de los cambios, agregue más pruebas. Son útiles para asegurarse de que los cambios no afecten la funcionalidad existente.
Si su paquete depende de llamar a paquetes de nivel superior: considere cambiar las funciones relacionadas a DEPRECATED y luego agregue nuevas funciones con el nuevo tipo Error
.
Supongamos que está migrando el paquete de base de datos, 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 utiliza en el paquete de 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 primero está migrando el paquete database
, dejando el user
y docenas de otros paquetes como están. La llamada s.repo.CreateUser()
aún devuelve el tipo de error
anterior, mientras que el método Transaction()
debe devolver el nuevo tipo Error
. Puede 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) }
Agregue nuevos códigos de error a medida que avanza : cuando encuentre un error que no se ajuste a los existentes, agregue un nuevo código. Esto le ayudará a crear un conjunto completo de códigos de error a lo largo del tiempo. Los códigos de otros paquetes siempre están disponibles como referencias.
Al principio, el manejo de errores en Go puede parecer simple: basta con devolver un error
y continuar. Pero a medida que nuestra base de código fue creciendo, esa simplicidad se convirtió en una maraña de registros imprecisos, manejo inconsistente y sesiones de depuración interminables.
Al dar un paso atrás y repensar cómo manejamos los errores, hemos creado un sistema que funciona para nosotros, no en nuestra contra. Los códigos de espacio de nombres centralizados y estructurados nos brindan claridad, mientras que las herramientas para mapear, encapsular y probar errores nos hacen la vida más fácil. En lugar de nadar en un mar de registros, ahora tenemos errores significativos y rastreables que nos indican qué está mal y dónde buscar.
Este marco no solo sirve para hacer que nuestro código sea más limpio, sino para ahorrar tiempo, reducir la frustración y ayudarnos a prepararnos para lo desconocido. Es solo el comienzo de un viaje (aún estamos descubriendo más patrones), pero el resultado es un sistema que, de alguna manera, puede brindar tranquilidad en cuanto al manejo de errores. ¡Con suerte, también puede generar algunas ideas para tus proyectos! 😊
Soy Oliver Nguyen. Soy un desarrollador de software que trabaja principalmente con Go y JavaScript. Disfruto aprendiendo y viendo una mejor versión de mí mismo cada día. Ocasionalmente desarrollo nuevos proyectos de código abierto. Comparto conocimientos y pensamientos durante mi recorrido.
La publicación también está publicada en blog.connectly.ai y olivernguyen.io 👋