El enfoque idiomático: errores como valores y retornos explícitos
En Go, el flujo de errores se maneja devolviendo un valor error junto con el resultado. Esto hace que el control sea explícito: cada llamada decide si propaga, transforma o maneja el error. La regla práctica es simple: si una función puede fallar, devuelve (T, error) (o solo error si no hay resultado).
func ReadConfig(path string) ([]byte, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } return b, nil}Este patrón evita excepciones implícitas y facilita construir software robusto, especialmente en servicios y herramientas de línea de comandos, donde la trazabilidad del fallo es tan importante como el fallo en sí.
Regla de oro: manejar, enriquecer o propagar
Cuando recibes un err, normalmente harás una de estas tres cosas:
- Manejar: puedes resolver el problema localmente (reintentar, usar un valor por defecto, pedir otra entrada).
- Enriquecer: añades contexto para que el error sea entendible en capas superiores.
- Propagar: lo devuelves tal cual si no puedes aportar nada útil.
Envolviendo errores con contexto (wrapping)
El wrapping es clave para la trazabilidad: mantienes el error original y agregas información del punto donde ocurrió. Se hace con fmt.Errorf y %w.
func LoadUserProfile(path string) ([]byte, error) { b, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("leyendo perfil %q: %w", path, err) } return b, nil}Esto permite que capas superiores inspeccionen el error original (por ejemplo, “archivo no existe”) sin perder el contexto (“leyendo perfil ...”).
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
Guía práctica paso a paso: validación de entrada con errores útiles
Un caso real común: validar datos antes de ejecutar lógica de negocio. La idea es devolver errores descriptivos y, cuando convenga, errores tipados para que el llamador pueda reaccionar.
Paso 1: define un error de validación tipado
type ValidationError struct { Field string Msg string}func (e *ValidationError) Error() string { return fmt.Sprintf("validación: %s %s", e.Field, e.Msg)}Paso 2: valida y devuelve el error
func ParsePort(s string) (int, error) { p, err := strconv.Atoi(s) if err != nil { return 0, &ValidationError{Field: "port", Msg: "no es un número"} } if p < 1 || p > 65535 { return 0, &ValidationError{Field: "port", Msg: "fuera de rango (1-65535)"} } return p, nil}Paso 3: inspecciona con errors.As para decidir qué hacer
p, err := ParsePort(input)if err != nil { var ve *ValidationError if errors.As(err, &ve) { // Error recuperable: pedir al usuario que corrija el campo fmt.Printf("Entrada inválida en %s: %s\n", ve.Field, ve.Msg) return } // Otro tipo de error inesperado: propagar o registrar log.Printf("error inesperado: %v", err) return}Observa el objetivo: errores recuperables (entrada inválida) se manejan localmente; errores no previstos se registran/propagan.
Errores sentinela vs errores tipados: cuándo usar cada uno
Errores sentinela (variables)
Un error sentinela es una variable exportada o interna que representa una condición específica. Útil cuando el llamador solo necesita saber “qué pasó” sin más datos.
var ErrNotFound = errors.New("no encontrado")func FindUser(id string) (User, error) { if id == "" { return User{}, ErrNotFound } // ... return User{}, ErrNotFound}Para compararlos correctamente (incluyendo wrapping), usa errors.Is:
u, err := FindUser(id)if err != nil { if errors.Is(err, ErrNotFound) { // responder 404, o mostrar mensaje al usuario return } return}Errores tipados (structs) con datos
Cuando necesitas adjuntar información (campo, código, operación, endpoint), un tipo es mejor que un sentinela. Se inspecciona con errors.As.
type NotFoundError struct { Resource string ID string}func (e *NotFoundError) Error() string { return fmt.Sprintf("%s con id=%s no existe", e.Resource, e.ID)}Acceso a archivos: distinguir causas y mejorar trazabilidad
En IO es frecuente querer distinguir “no existe” de “permiso denegado” y, a la vez, conservar el contexto de la operación.
func ReadReport(path string) ([]byte, error) { b, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("leyendo reporte %q: %w", path, err) } return b, nil}En el llamador, puedes inspeccionar el error original usando helpers del paquete os (que funcionan incluso si el error está envuelto):
b, err := ReadReport("./reports/today.txt")if err != nil { switch { case os.IsNotExist(err): log.Printf("reporte no encontrado: %v", err) case os.IsPermission(err): log.Printf("sin permisos: %v", err) default: log.Printf("fallo leyendo reporte: %v", err) } return}El mensaje final tendrá contexto (“leyendo reporte ...”) y podrás clasificar la causa (“no existe”, “permiso”).
Errores de red: timeouts, cancelación y reintentos
En red, muchos errores son recuperables (reintentar) y otros deben cortar rápido (context cancelado). Es buena práctica usar context.Context para cancelación y deadlines, y envolver errores con la operación que falló.
func FetchJSON(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creando request: %w", err) } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("haciendo GET %s: %w", url, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("GET %s: status %d", url, resp.StatusCode) } b, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("leyendo body %s: %w", url, err) } return b, nil}Inspección: distinguir timeout de cancelación
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)defer cancel()_, err := FetchJSON(ctx, "https://api.example.com/data")if err != nil { if errors.Is(err, context.DeadlineExceeded) { log.Printf("timeout: %v", err) return } if errors.Is(err, context.Canceled) { log.Printf("cancelado: %v", err) return } // También puedes detectar errores de red con net.Error (cuando aplique) var ne net.Error if errors.As(err, &ne) && ne.Timeout() { log.Printf("timeout de red: %v", err) return } log.Printf("error de red: %v", err)}Reintento simple con backoff (solo para errores recuperables)
Un reintento debe ser conservador: no reintentes si el contexto está cancelado o si el error no es transitorio. Ejemplo minimalista:
func FetchWithRetry(ctx context.Context, url string, attempts int) ([]byte, error) { var last error delay := 200 * time.Millisecond for i := 0; i < attempts; i++ { b, err := FetchJSON(ctx, url) if err == nil { return b, nil } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return nil, err } // Si detectas timeout de red, podría ser transitorio var ne net.Error if errors.As(err, &ne) && ne.Timeout() { last = err } else { // No parece recuperable: no reintentar return nil, err } t := time.NewTimer(delay) select { case <-ctx.Done(): t.Stop() return nil, ctx.Err() case <-t.C: } delay *= 2 } return nil, fmt.Errorf("agotados reintentos para %s: %w", url, last)}Cuándo usar panic/recover: condiciones fatales vs errores recuperables
panic no es el mecanismo normal de errores en Go. Se reserva para condiciones fatales o programming errors: invariantes rotas, estados imposibles, índices fuera de rango por bug, nil inesperado, etc. En cambio, fallos esperables (archivo no existe, input inválido, timeout) deben ser error.
- Usa error cuando el llamador puede decidir qué hacer.
- Usa panic cuando continuar sería incorrecto y el problema es un bug o una corrupción de estado.
- Usa recover solo en bordes controlados (por ejemplo, un servidor para evitar que una petición tumbe el proceso) y siempre registrando lo ocurrido.
Ejemplo: middleware de recuperación en un servidor HTTP
func RecoverMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { // Registrar con contexto de request; en producción, añade stacktrace si lo necesitas log.Printf("panic en %s %s: %v", r.Method, r.URL.Path, rec) http.Error(w, "error interno", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) })}Este patrón evita caídas del proceso por un bug en una ruta. Aun así, el objetivo es corregir el bug; no “normalizar” el uso de panic como control de flujo.
Diseño de mensajes de error: claridad, contexto y consistencia
Un buen error debe ayudar a responder: ¿qué operación falló?, ¿sobre qué recurso?, ¿por qué?, ¿es recuperable? Recomendaciones prácticas:
- Incluye la operación en el mensaje: “abriendo archivo”, “haciendo GET”, “parseando configuración”.
- Incluye identificadores relevantes: rutas, URLs, IDs (evita datos sensibles).
- Envuelve el error original con
%wpara no perder la causa. - Evita mayúsculas iniciales y puntos finales en mensajes de error (convención común en Go).
| Situación | Mejor práctica | Ejemplo |
|---|---|---|
| Fallo en capa baja (IO) | Envolver con contexto | fmt.Errorf("abriendo %q: %w", path, err) |
| Validación | Error tipado con datos | &ValidationError{Field:"email", Msg:"formato inválido"} |
| Condición conocida | Sentinela + errors.Is | ErrNotFound |
| Bug/invariante rota | panic (y recover en borde) | panic("estado imposible") |