Advanced Error Handling Strategies in Go
Advanced Error Handling Strategies in Go
Error handling in Go isn't just about if err != nil
- here's how to do it right.
🎯 The Problem with Basic Error Handling
Most Go developers start with this pattern:
// ❌ Basic but limited error handling
func processUser(id string) error {
user, err := getUserFromDB(id)
if err != nil {
return err
}
err = validateUser(user)
if err != nil {
return err
}
err = sendWelcomeEmail(user)
if err != nil {
return err
}
return nil
}
Problems:
- ❌ Lost context about where error occurred
- ❌ No distinction between error types
- ❌ Difficult to handle errors differently
- ❌ Poor debugging experience
✅ Advanced Error Handling Patterns
1. Custom Error Types
Create meaningful error types that carry context:
// Define error types for different categories
type ValidationError struct {
Field string
Value interface{}
Rule string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("validation failed: field '%s' with value '%v' violates rule '%s'",
e.Field, e.Value, e.Rule)
}
type DatabaseError struct {
Operation string
Table string
Cause error
}
func (e DatabaseError) Error() string {
return fmt.Sprintf("database error during %s on table %s: %v",
e.Operation, e.Table, e.Cause)
}
func (e DatabaseError) Unwrap() error {
return e.Cause
}
type ExternalServiceError struct {
Service string
StatusCode int
Message string
}
func (e ExternalServiceError) Error() string {
return fmt.Sprintf("external service '%s' failed (HTTP %d): %s",
e.Service, e.StatusCode, e.Message)
}
// Check if error is retryable
func (e ExternalServiceError) IsRetryable() bool {
return e.StatusCode >= 500 || e.StatusCode == 429
}
2. Error Wrapping with Context
Use fmt.Errorf
with %w
verb for error chains:
func processUser(id string) error {
user, err := getUserFromDB(id)
if err != nil {
return fmt.Errorf("failed to process user %s: %w", id, err)
}
if err := validateUser(user); err != nil {
return fmt.Errorf("user validation failed for %s: %w", id, err)
}
if err := sendWelcomeEmail(user); err != nil {
return fmt.Errorf("failed to send welcome email to %s: %w", user.Email, err)
}
return nil
}
func getUserFromDB(id string) error {
// Simulate database error
if rand.Float32() < 0.1 {
return DatabaseError{
Operation: "SELECT",
Table: "users",
Cause: errors.New("connection timeout"),
}
}
return nil
}
3. Sentinel Errors for Known Conditions
Define package-level errors for expected conditions:
package userservice
import "errors"
// Sentinel errors
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidEmail = errors.New("invalid email format")
ErrEmailAlreadyUsed = errors.New("email already in use")
ErrAccountLocked = errors.New("account is locked")
ErrInsufficientFunds = errors.New("insufficient funds")
)
func GetUser(id string) (*User, error) {
user, err := db.QueryUser(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("database query failed: %w", err)
}
return user, nil
}
// Usage with errors.Is()
func handleGetUser(id string) {
user, err := GetUser(id)
if err != nil {
switch {
case errors.Is(err, ErrUserNotFound):
log.Printf("User %s not found, creating new record", id)
// Handle not found case
case errors.Is(err, ErrAccountLocked):
log.Printf("User %s account is locked", id)
// Handle locked account
default:
log.Printf("Unexpected error: %v", err)
// Handle other errors
}
return
}
// Process user...
}
4. Error Interfaces for Behavior
Define interfaces to classify error behavior:
// Retryable interface
type Retryable interface {
IsRetryable() bool
}
// Temporary interface (from net package)
type Temporary interface {
Temporary() bool
}
// UserFacing interface for client errors
type UserFacing interface {
UserMessage() string
}
// Implement interfaces on custom errors
type PaymentError struct {
Code string
Message string
Retry bool
}
func (e PaymentError) Error() string {
return fmt.Sprintf("payment failed [%s]: %s", e.Code, e.Message)
}
func (e PaymentError) IsRetryable() bool {
return e.Retry
}
func (e PaymentError) UserMessage() string {
switch e.Code {
case "INSUFFICIENT_FUNDS":
return "You don't have enough funds for this transaction"
case "CARD_DECLINED":
return "Your card was declined. Please try a different payment method"
default:
return "Payment processing failed. Please try again later"
}
}
// Smart retry logic
func processPaymentWithRetry(amount int) error {
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
err := processPayment(amount)
if err == nil {
return nil
}
lastErr = err
// Check if error is retryable
var retryable Retryable
if errors.As(err, &retryable) && retryable.IsRetryable() {
log.Printf("Payment attempt %d failed (retryable): %v", attempt, err)
time.Sleep(time.Duration(attempt) * time.Second)
continue
}
// Non-retryable error
return err
}
return fmt.Errorf("payment failed after 3 attempts: %w", lastErr)
}
5. Result Type Pattern (Go 1.18+)
For functions that commonly fail, consider a Result type:
// Generic Result type
type Result[T any] struct {
Value T
Error error
}
func (r Result[T]) IsOK() bool {
return r.Error == nil
}
func (r Result[T]) Unwrap() (T, error) {
return r.Value, r.Error
}
func Ok[T any](value T) Result[T] {
return Result[T]{Value: value}
}
func Err[T any](err error) Result[T] {
var zero T
return Result[T]{Value: zero, Error: err}
}
// Usage
func GetUserResult(id string) Result[*User] {
user, err := getUserFromDB(id)
if err != nil {
return Err[*User](err)
}
return Ok(user)
}
// Chain operations
func ProcessUserChain(id string) Result[string] {
return GetUserResult(id).
AndThen(validateUserResult).
AndThen(sendEmailResult).
Map(func(user *User) string {
return fmt.Sprintf("Processed user: %s", user.Email)
})
}
// Result methods
func (r Result[T]) AndThen(f func(T) Result[U]) Result[U] {
if r.Error != nil {
return Err[U](r.Error)
}
return f(r.Value)
}
func (r Result[T]) Map(f func(T) U) Result[U] {
if r.Error != nil {
return Err[U](r.Error)
}
return Ok(f(r.Value))
}
6. Structured Error Context
Add structured context to errors for better observability:
type ErrorContext struct {
Operation string
UserID string
RequestID string
Component string
Metadata map[string]interface{}
}
type ContextualError struct {
Context ErrorContext
Cause error
}
func (e ContextualError) Error() string {
return fmt.Sprintf("[%s:%s] %s failed for user %s: %v",
e.Context.Component, e.Context.RequestID, e.Context.Operation, e.Context.UserID, e.Cause)
}
func (e ContextualError) Unwrap() error {
return e.Cause
}
// Helper function to wrap errors with context
func WithContext(ctx ErrorContext, err error) error {
if err == nil {
return nil
}
return ContextualError{Context: ctx, Cause: err}
}
// Usage
func processOrderWithContext(orderID, userID, requestID string) error {
ctx := ErrorContext{
Operation: "process_order",
UserID: userID,
RequestID: requestID,
Component: "order-service",
Metadata: map[string]interface{}{
"order_id": orderID,
},
}
order, err := getOrder(orderID)
if err != nil {
return WithContext(ctx, fmt.Errorf("failed to fetch order: %w", err))
}
err = validateOrder(order)
if err != nil {
ctx.Metadata["validation_step"] = "order_validation"
return WithContext(ctx, fmt.Errorf("order validation failed: %w", err))
}
err = chargePayment(order.PaymentMethod, order.Total)
if err != nil {
ctx.Metadata["payment_amount"] = order.Total
return WithContext(ctx, fmt.Errorf("payment processing failed: %w", err))
}
return nil
}
7. Error Aggregation
Handle multiple errors elegantly:
type MultiError struct {
Errors []error
}
func (me MultiError) Error() string {
if len(me.Errors) == 0 {
return "no errors"
}
if len(me.Errors) == 1 {
return me.Errors[0].Error()
}
var msgs []string
for _, err := range me.Errors {
msgs = append(msgs, err.Error())
}
return fmt.Sprintf("multiple errors: %s", strings.Join(msgs, "; "))
}
func (me *MultiError) Add(err error) {
if err != nil {
me.Errors = append(me.Errors, err)
}
}
func (me MultiError) HasErrors() bool {
return len(me.Errors) > 0
}
// Usage: Validate multiple fields
func validateUser(user *User) error {
var errors MultiError
if user.Email == "" {
errors.Add(ValidationError{Field: "email", Value: user.Email, Rule: "required"})
} else if !isValidEmail(user.Email) {
errors.Add(ValidationError{Field: "email", Value: user.Email, Rule: "format"})
}
if user.Age < 0 {
errors.Add(ValidationError{Field: "age", Value: user.Age, Rule: "positive"})
}
if len(user.Name) < 2 {
errors.Add(ValidationError{Field: "name", Value: user.Name, Rule: "min_length"})
}
if errors.HasErrors() {
return errors
}
return nil
}
8. Error Recovery Patterns
Implement graceful degradation:
// Circuit breaker pattern for error recovery
type CircuitBreaker struct {
failures int
threshold int
timeout time.Duration
lastFailure time.Time
state string // "closed", "open", "half-open"
mu sync.Mutex
}
func (cb *CircuitBreaker) Call(fn func() error) error {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state == "open" {
if time.Since(cb.lastFailure) > cb.timeout {
cb.state = "half-open"
} else {
return errors.New("circuit breaker is open")
}
}
err := fn()
if err != nil {
cb.failures++
cb.lastFailure = time.Now()
if cb.failures >= cb.threshold {
cb.state = "open"
}
return err
}
// Success - reset circuit breaker
cb.failures = 0
cb.state = "closed"
return nil
}
// Fallback pattern
func getUserWithFallback(id string) (*User, error) {
// Try primary database
user, err := getUserFromPrimaryDB(id)
if err == nil {
return user, nil
}
log.Printf("Primary DB failed: %v, trying cache", err)
// Fallback to cache
user, err = getUserFromCache(id)
if err == nil {
return user, nil
}
log.Printf("Cache failed: %v, trying replica", err)
// Fallback to read replica
user, err = getUserFromReplica(id)
if err == nil {
return user, nil
}
return nil, fmt.Errorf("all data sources failed for user %s: %w", id, err)
}
9. Error Monitoring and Observability
Integrate with monitoring systems:
// Error severity levels
type Severity int
const (
SeverityInfo Severity = iota
SeverityWarning
SeverityError
SeverityCritical
)
type MonitoredError struct {
error
Severity Severity
Tags map[string]string
Timestamp time.Time
StackTrace string
}
func NewMonitoredError(err error, severity Severity, tags map[string]string) *MonitoredError {
return &MonitoredError{
error: err,
Severity: severity,
Tags: tags,
Timestamp: time.Now(),
StackTrace: getStackTrace(),
}
}
// Error middleware for HTTP handlers
func ErrorMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
// Log panic as critical error
logError(fmt.Errorf("panic: %v", err), SeverityCritical, map[string]string{
"path": r.URL.Path,
"method": r.Method,
})
http.Error(w, "Internal Server Error", 500)
}
}()
next(w, r)
}
}
func logError(err error, severity Severity, tags map[string]string) {
monitoredErr := NewMonitoredError(err, severity, tags)
// Send to logging system
log.Printf("[%s] %v", severityString(severity), err)
// Send metrics to monitoring system (Prometheus, DataDog, etc.)
incrementErrorCounter(tags)
// For critical errors, send alerts
if severity == SeverityCritical {
sendAlert(monitoredErr)
}
}
🎯 Best Practices Summary
✅ DO:
- Use custom error types for different error categories
- Wrap errors with context using
fmt.Errorf
and%w
- Use sentinel errors for expected conditions
- Implement error interfaces for behavior classification
- Add structured context to errors for debugging
- Handle multiple errors when validating multiple fields
- Implement fallbacks for graceful degradation
- Monitor and alert on critical errors
❌ DON'T:
- Ignore errors - always handle them appropriately
- Use generic error messages - be specific about what failed
- Lose error context - preserve the error chain
- Handle all errors the same way - different errors need different handling
- Log and return the same error (choose one)
- Use panics for expected errors - panics are for programmer errors
- Create deep error wrapping - keep error chains manageable
🚀 Real-World Example
Here's how to apply these patterns in a user registration service:
// registration_service.go
package main
import (
"fmt"
"errors"
"context"
)
type RegistrationService struct {
userRepo UserRepository
emailSender EmailSender
validator UserValidator
}
func (s *RegistrationService) RegisterUser(ctx context.Context, req RegisterRequest) (*User, error) {
// Error context for this operation
errCtx := ErrorContext{
Operation: "register_user",
RequestID: getRequestID(ctx),
Component: "registration-service",
Metadata: map[string]interface{}{
"email": req.Email,
},
}
// Validate input
if err := s.validator.Validate(req); err != nil {
return nil, WithContext(errCtx, fmt.Errorf("validation failed: %w", err))
}
// Check if user already exists
existingUser, err := s.userRepo.GetByEmail(req.Email)
if err != nil && !errors.Is(err, ErrUserNotFound) {
return nil, WithContext(errCtx, fmt.Errorf("failed to check existing user: %w", err))
}
if existingUser != nil {
return nil, WithContext(errCtx, ErrEmailAlreadyUsed)
}
// Create user
user := &User{
Email: req.Email,
Name: req.Name,
Password: hashPassword(req.Password),
}
if err := s.userRepo.Create(user); err != nil {
return nil, WithContext(errCtx, fmt.Errorf("failed to create user: %w", err))
}
// Send welcome email (non-critical - log error but don't fail)
if err := s.emailSender.SendWelcome(user.Email); err != nil {
log.Printf("Failed to send welcome email to %s: %v", user.Email, err)
// Continue - email failure shouldn't fail registration
}
return user, nil
}
// Error handling in HTTP handler
func (h *Handler) RegisterUserHandler(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
user, err := h.service.RegisterUser(r.Context(), req)
if err != nil {
// Check error type and respond appropriately
var validationErr MultiError
if errors.As(err, &validationErr) {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Validation failed",
"details": validationErr.Errors,
})
return
}
if errors.Is(err, ErrEmailAlreadyUsed) {
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{
"error": "Email already registered",
})
return
}
// Log unexpected errors
log.Printf("Registration failed: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
The key insight: Good error handling isn't just about catching errors - it's about providing context, enabling recovery, and making your systems more observable and maintainable.
Master these patterns and your Go code will be more robust, debuggable, and production-ready!
Wang Yinneng
Senior Golang Backend & Web3 Developer with 10+ years of experience building scalable systems and blockchain solutions.
View Full Profile →