paint-brush
Evite que los errores aumenten con este nuevo marcopor@olvrng
270 lecturas

Evite que los errores aumenten con este nuevo marco

por Oliver Nguyen30m2024/12/11
Read on Terminal Reader

Demasiado Largo; Para Leer

Esta es la historia de cómo empezamos con un enfoque de manejo de errores simple, nos frustramos muchísimo a medida que los problemas crecieron y finalmente construimos nuestro propio marco de manejo de errores.
featured image - Evite que los errores aumenten con este nuevo marco
Oliver Nguyen HackerNoon profile picture
0-item
1-item

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!


Los errores de Go son solo valores

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.

Cada paquete necesita lidiar con los errores.

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:

  • Creación de errores: errors.New() y fmt.Errorf() para generar errores simples.
  • Envoltura de errores: envuelva los errores con contexto adicional utilizando fmt.Errorf() y el verbo %w .
  • Combinación de errores: errors.Join() fusiona varios errores en uno solo.
  • Comprobación y gestión de errores: 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:

  • Uso de paquetes estándar: Devolver errores simples con errors.New() o fmt.Errorf() .
  • Exportación de constantes o variables: por ejemplo, go-redis y gorm.io definen variables de error reutilizables.
  • Tipos de error personalizados: las bibliotecas como lib/pq grpc/status.Error crean tipos de error especializados, a menudo con códigos asociados para contexto adicional.
  • Interfaces de error con implementaciones: aws-sdk-go utiliza un enfoque basado en interfaz para definir tipos de error con varias implementaciones.
  • O múltiples interfaces: como errdefs de Docker , que define múltiples interfaces para clasificar y gestionar errores.

Comenzamos con un enfoque común

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.


Y los problemas crecieron con el tiempo.

Se declararon errores en todas partes.

  • Cada paquete definía sus propias constantes de error sin un sistema centralizado.
  • Las constantes y los mensajes estaban dispersos en el código base, lo que hacía que no quedara claro qué errores podría devolver una función: ¿es gorm.ErrRecordNotFound o user.ErrNotFound o ambos?


El error aleatorio de envoltura generó registros inconsistentes y arbitrarios

  • Muchas funciones envolvieron errores con mensajes arbitrarios e inconsistentes sin declarar sus propios tipos de error.
  • Los registros eran extensos, redundantes y difíciles de buscar o monitorear.
  • Los mensajes de error eran genéricos y, a menudo, no explicaban qué había salido mal ni cómo había sucedido. Además, eran frágiles y propensos a cambios inadvertidos.
 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

  • Cada paquete manejaba los errores de forma diferente, lo que hacía difícil saber si una función devolvía, envolvía o transformaba errores.
  • A menudo se perdía el contexto a medida que se propagaban los errores.
  • Las capas superiores recibieron vagos errores internos del servidor 500 sin causas raíz claras.


No existe categorización que haga imposible el seguimiento

  • Los errores no se clasificaron por gravedad o comportamiento: un error 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.
  • Los problemas importantes quedaron enterrados bajo registros ruidosos, lo que hizo que fuera difícil identificarlos.
  • Sin categorización, era imposible monitorear eficazmente la frecuencia, la gravedad o el impacto de los errores.

Es hora de centralizar la gestión de errores

De vuelta a la mesa de dibujo

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 .

  • Los errores se declaran en todas partes → Centralice la declaración de errores en un solo lugar para una mejor organización y trazabilidad.
  • Registros inconsistentes y arbitrarios → Códigos de error estructurados con formato claro y consistente.
  • Manejo inadecuado de errores → Estandarice la creación y verificación de errores en el nuevo tipo de Error con un conjunto integral de ayudantes.
  • Sin categorización → Clasifique los códigos de error con etiquetas para un monitoreo efectivo a través de registros y métricas.

Decisiones de diseño

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".
  • Ambos pueden compartir un código base subyacente 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 .

  • Cada capa de servicio, repositorio o biblioteca declara su propio conjunto de códigos de error.
  • Cuando una capa recibe un error de una dependencia, debe envolverlo con su propio código de espacio de nombres antes de devolverlo.
  • Por ejemplo: cuando se recibe un error 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 .

  • Esto crea un límite claro entre los errores de bibliotecas de terceros ( error ) y nuestros Error internos.
  • Esto también ayuda al progreso de la migración, a separar entre paquetes migrados y aquellos que aún no se han migrado.


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.

  • Muchas veces vimos registros con errores independientes sin contexto, sin trace_id y no tenemos idea de dónde provienen.
  • Se pueden adjuntar claves/valores adicionales a los errores, que se pueden usar en registros o en la monitorización.


Cuando se envían errores a través del límite del servicio, solo se expone el código de error de nivel superior.

  • Las personas que llaman no necesitan ver los detalles de implementación interna de ese servicio.


Para errores externos, siga utilizando el ErrorCode y ErrorType de Protobuf actuales.

  • Esto garantiza la compatibilidad con versiones anteriores, por lo que nuestros clientes no necesitan reescribir su código.


Asigna automáticamente códigos de error de espacio de nombres a códigos Protobuf, códigos de estado HTTP y etiquetas.

  • Los ingenieros definen la asignación en el lugar centralizado, y el marco asignará cada código de error al Protobuf ErrorCode , ErrorType , estado gRPC, estado HTTP y etiquetas para registro/métricas correspondientes.
  • Esto garantiza la coherencia y reduce la duplicación.

El marco de error del espacio de nombres

Paquetes básicos y tipos

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) { /* ... */ }

Ejemplo de uso

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.

Creando y envolviendo errores

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 .
  • Un Code expone métodos como New() o Wrap() para crear un nuevo error.
  • La función 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, "...") } }

Añadiendo contexto a los errores

Agregar contexto a un error usando With()

  • Puede agregar pares clave/valor adicionales a los errores mediante .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}

Diferentes tipos: 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.

Declaración de nuevos códigos de error

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:

  • El código UNEXPECTED se utiliza para errores que nunca deberían ocurrir.
  • El código UNKNOWN se utiliza para errores que no se manejan explícitamente.

Asignación de errores a código nuevo

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))

Prueba con espacio de nombres 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.


Migración

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.


Conclusión

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! 😊



Autor

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 👋