Connectly
Ingeniería2024-12-05

Errores, errores en todas partes: cómo centralizamos y estructuramos el manejo de errores en Go

Por Oliver Nguyen

Errores, errores en todas partes: cómo centralizamos y estructuramos el manejo de errores en Go

¡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.

Los errores en Go son solo valores

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.

Cada paquete necesita lidiar con errores

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.

Y los problemas crecieron con el tiempo

Los errores se declaraban en todas partes

  • Cada paquete definía sus propias constantes de error sin un sistema centralizado.
  • Las constantes y mensajes estaban dispersos por todo el codebase, dejando poco claro qué errores podría devolver una función -- uf, ¿es gorm.ErrRecordNotFound o user.ErrNotFound o ambos?

El envoltorio aleatorio de errores generó logs inconsistentes y arbitrarios

  • Muchas funciones envolvían errores con mensajes arbitrarios e inconsistentes sin declarar sus propios tipos de error.
  • Los logs eran verbosos, redundantes y difíciles de buscar o monitorear.
  • Los mensajes de error eran genéricos y a menudo no explicaban qué salió mal ni cómo sucedió. 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 llevó a un manejo de errores inapropiado

  • Cada paquete manejaba los errores de manera diferente, dificultando saber si una función devolvía, envolvía o transformaba errores.
  • El contexto a menudo se perdía a medida que los errores se propagaban.
  • Las capas superiores recibían vagos errores 500 Internal Server Error sin causas raíz claras.

La falta de categorización hizo imposible el monitoreo

  • Los errores no se clasificaban por severidad 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 quedaban enterrados bajo logs ruidosos, dificultando identificarlos.
  • Sin categorización, era imposible monitorear la frecuencia, severidad o impacto de los errores de manera efectiva.

Es momento de centralizar el manejo de errores

De vuelta al pizarrón

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.

  • Los errores se declaran en todas partes → Centralizar la declaración de errores en un solo lugar para mejor organización y trazabilidad.
  • Logs inconsistentes y arbitrarios → Códigos de error estructurados con formato claro y consistente.
  • Manejo de errores inapropiado → Estandarizar la creación y verificación de errores en el nuevo tipo Error con un conjunto completo de helpers.
  • Falta de categorización → Categorizar los códigos de error con etiquetas para un monitoreo efectivo a través de logs y métricas.

Decisiones de diseño

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:

  • 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 librería solo debe devolver sus propios códigos de namespace.

  • Cada capa de servicio, repositorio o librería 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 namespace antes de devolverlo.
  • Por ejemplo: al recibir un error gorm.ErrRecordNotFound de una dependencia, el paquete "database" debe envolverlo como DEPS.PG.NOT_FOUND. Más adelante, el servicio "profile/user" debe envolverlo de nuevo como PRFL.USR.NOT_FOUND.

Todos los errores deben implementar la interfaz Error.

  • Esto crea un límite claro entre los errores de librerías de terceros (error) y nuestros Errors internos.
  • Esto también ayuda con el progreso de la migración, para separar los paquetes ya migrados de los que aún no lo están.

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.

  • Muchas veces vimos logs con errores independientes sin contexto, sin trace_id, y sin idea de dónde venían.
  • Se puede adjuntar clave/valor adicional a los errores, que se puede usar en logs o monitoreo.

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.

  • Quienes llaman no necesitan ver los detalles internos de implementación de ese servicio.

Para errores externos, seguir usando el ErrorCode y ErrorType actuales de Protobuf.

  • Esto asegura compatibilidad hacia atrás, así nuestros clientes no necesitan reescribir su código.

Mapeo automático de códigos de error de namespace a códigos de Protobuf, códigos de estado HTTP y etiquetas.

  • Los ingenieros definen el mapeo en el lugar centralizado, y el framework mapeará cada código de error al ErrorCode, ErrorType, estado gRPC, estado HTTP y etiquetas correspondientes para logging/métricas.
  • Esto asegura consistencia y reduce la duplicación.

El framework de errores con namespace

Paquetes y tipos principales

Hay unos pocos paquetes principales que forman la base de nuestro nuevo framework 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 helper pensado para usarse con dot import.
  • testing: Utilidades de prueba para trabajar con errores de namespace.

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

Ejemplo de uso

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.

Creando y envolviendo errores

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")
  • 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.

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, "...")
    }
}

Agregando contexto a los errores

Agrega contexto a un error usando With()

  • Puedes agregar pares clave/valor adicionales a los errores con .With(l.String(...)).
  • logging/l es un paquete helper que exporta funciones de azúcar sintáctica para logging.
  • l.String("flag", flag) devuelve un 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 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}

Diferentes tipos: Error0, VlError, ApiError

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.

Declarando nuevos códigos de error

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

  • El código de error de namespace debe usarse internamente.
  • Para hacer que un código esté disponible para devolverse en la API HTTP externa, necesitas 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 genérico Internal Server Error.
  • Nota que PRFL.USR.NOT_FOUND es código externo, mientras que PRFL.USR.REPO.NOT_FOUND es código interno.

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:

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

Mapeando errores al nuevo código

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

  • IsErrorCode(err, CODES...): Verifica si el error contiene alguno de los códigos especificados.
  • IsErrorGroup(err, GROUP): Devuelve true 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 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))

Pruebas con errores de namespace

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.

Migración

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.

Conclusión

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

Autor

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.