← All Posts

Go Error Handling Patterns for Production APIs: Beyond Basic Error Returns

Matthias Bruns · · 8 min read
Go API Design Error Handling Production

Production APIs demand bulletproof error handling. While Go’s explicit error handling gets you started, building resilient systems requires sophisticated patterns that go far beyond basic if err != nil checks. This guide covers advanced error handling techniques that will make your Go APIs production-ready, with proper observability, debugging capabilities, and user-friendly responses.

The Foundation: Structured Error Types

Basic error strings don’t cut it in production. You need structured errors that carry context, status codes, and metadata. Here’s a robust error type that forms the foundation of production error handling:

type APIError struct {
    Code       string            `json:"code"`
    Message    string            `json:"message"`
    StatusCode int               `json:"-"`
    Internal   error             `json:"-"`
    Fields     map[string]string `json:"fields,omitempty"`
    RequestID  string            `json:"request_id,omitempty"`
}

func (e *APIError) Error() string {
    if e.Internal != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Internal)
    }
    return e.Message
}

func (e *APIError) Unwrap() error {
    return e.Internal
}

This structure separates public-facing messages from internal error details—a critical security practice. As JetBrains notes, you should “wrap your errors in generic messages when crossing public boundaries, like from your public API gateway to the end user.”

Error Wrapping and Context Propagation

Go’s error wrapping capabilities shine when you need to trace errors through complex call stacks. The key is adding context at each layer while preserving the original error:

func (s *UserService) GetUser(ctx context.Context, userID string) (*User, error) {
    user, err := s.repo.FindByID(ctx, userID)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            return nil, &APIError{
                Code:       "USER_NOT_FOUND",
                Message:    "User not found",
                StatusCode: http.StatusNotFound,
                Internal:   fmt.Errorf("user lookup failed for ID %s: %w", userID, err),
                RequestID:  GetRequestID(ctx),
            }
        }
        return nil, fmt.Errorf("user service: failed to get user %s: %w", userID, err)
    }
    return user, nil
}

This approach follows Go’s philosophy that errors should be handled or passed to the caller until some function up the call chain handles the error, while adding valuable context at each layer.

HTTP Error Handler Pattern

Production APIs need consistent error responses. Implement a centralized error handler that translates internal errors into appropriate HTTP responses:

type ErrorHandler struct {
    logger *slog.Logger
}

func (h *ErrorHandler) HandleError(w http.ResponseWriter, r *http.Request, err error) {
    requestID := GetRequestID(r.Context())
    
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        h.writeErrorResponse(w, apiErr, requestID)
        h.logError(r.Context(), apiErr)
        return
    }
    
    // Unknown error - don't expose internals
    internalErr := &APIError{
        Code:       "INTERNAL_ERROR",
        Message:    "Internal server error",
        StatusCode: http.StatusInternalServerError,
        Internal:   err,
        RequestID:  requestID,
    }
    
    h.writeErrorResponse(w, internalErr, requestID)
    h.logError(r.Context(), internalErr)
}

func (h *ErrorHandler) writeErrorResponse(w http.ResponseWriter, err *APIError, requestID string) {
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Request-ID", requestID)
    w.WriteHeader(err.StatusCode)
    
    response := map[string]interface{}{
        "error": map[string]interface{}{
            "code":       err.Code,
            "message":    err.Message,
            "request_id": requestID,
        },
    }
    
    if err.Fields != nil {
        response["error"].(map[string]interface{})["fields"] = err.Fields
    }
    
    json.NewEncoder(w).Encode(response)
}

Custom Handler Wrapper

Eliminate repetitive error handling in HTTP handlers with a wrapper pattern. This approach, inspired by Go’s official error handling guidance, creates cleaner handler functions:

type AppHandler func(http.ResponseWriter, *http.Request) error

func (fn AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        errorHandler.HandleError(w, r, err)
    }
}

// Usage in handlers
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) error {
    userID := mux.Vars(r)["id"]
    if userID == "" {
        return &APIError{
            Code:       "INVALID_USER_ID",
            Message:    "User ID is required",
            StatusCode: http.StatusBadRequest,
        }
    }
    
    user, err := h.service.GetUser(r.Context(), userID)
    if err != nil {
        return err // Let the wrapper handle it
    }
    
    return json.NewEncoder(w).Encode(user)
}

// Register with the wrapper
http.Handle("/users/{id}", AppHandler(userHandler.GetUser))

Validation Error Handling

Input validation deserves special attention in production APIs. Create specific error types for validation failures:

type ValidationError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
    Value   string `json:"value,omitempty"`
}

func NewValidationError(field, message, value string) *APIError {
    return &APIError{
        Code:       "VALIDATION_ERROR",
        Message:    "Validation failed",
        StatusCode: http.StatusBadRequest,
        Fields: map[string]string{
            field: message,
        },
    }
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) error {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        return &APIError{
            Code:       "INVALID_JSON",
            Message:    "Invalid JSON in request body",
            StatusCode: http.StatusBadRequest,
            Internal:   err,
        }
    }
    
    if req.Email == "" {
        return NewValidationError("email", "Email is required", "")
    }
    
    if !isValidEmail(req.Email) {
        return NewValidationError("email", "Invalid email format", req.Email)
    }
    
    // Continue with business logic...
}

Observability Integration

Production error handling must integrate with observability systems. Add structured logging, metrics, and tracing:

func (h *ErrorHandler) logError(ctx context.Context, err *APIError) {
    logLevel := slog.LevelError
    if err.StatusCode < 500 {
        logLevel = slog.LevelWarn
    }
    
    attrs := []slog.Attr{
        slog.String("error_code", err.Code),
        slog.String("error_message", err.Message),
        slog.Int("status_code", err.StatusCode),
        slog.String("request_id", err.RequestID),
    }
    
    if err.Internal != nil {
        attrs = append(attrs, slog.String("internal_error", err.Internal.Error()))
    }
    
    if traceID := GetTraceID(ctx); traceID != "" {
        attrs = append(attrs, slog.String("trace_id", traceID))
    }
    
    h.logger.LogAttrs(ctx, logLevel, "API error occurred", attrs...)
    
    // Increment metrics
    h.incrementErrorMetric(err.Code, err.StatusCode)
}

func (h *ErrorHandler) incrementErrorMetric(code string, statusCode int) {
    // Example with Prometheus metrics
    errorCounter.WithLabelValues(code, fmt.Sprintf("%d", statusCode)).Inc()
}

Circuit Breaker Error Handling

When your API depends on external services, implement circuit breaker patterns to handle cascading failures gracefully:

type CircuitBreakerError struct {
    Service string
    State   string
}

func (e *CircuitBreakerError) Error() string {
    return fmt.Sprintf("circuit breaker %s for service %s", e.State, e.Service)
}

func (s *PaymentService) ProcessPayment(ctx context.Context, req *PaymentRequest) error {
    err := s.circuitBreaker.Execute(func() error {
        return s.externalPaymentAPI.Process(ctx, req)
    })
    
    var cbErr *CircuitBreakerError
    if errors.As(err, &cbErr) {
        return &APIError{
            Code:       "SERVICE_UNAVAILABLE",
            Message:    "Payment service temporarily unavailable",
            StatusCode: http.StatusServiceUnavailable,
            Internal:   err,
        }
    }
    
    return err
}

Timeout and Context Error Handling

Context cancellation and timeouts require special handling to provide meaningful responses:

func (s *UserService) GetUserWithTimeout(ctx context.Context, userID string) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    
    user, err := s.repo.FindByID(ctx, userID)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, &APIError{
                Code:       "REQUEST_TIMEOUT",
                Message:    "Request timed out",
                StatusCode: http.StatusRequestTimeout,
                Internal:   err,
            }
        }
        
        if errors.Is(err, context.Canceled) {
            return nil, &APIError{
                Code:       "REQUEST_CANCELED",
                Message:    "Request was canceled",
                StatusCode: 499, // Client closed request
                Internal:   err,
            }
        }
        
        return nil, fmt.Errorf("failed to get user: %w", err)
    }
    
    return user, nil
}

Error Recovery and Graceful Degradation

Implement fallback mechanisms for non-critical failures:

func (s *RecommendationService) GetRecommendations(ctx context.Context, userID string) (*Recommendations, error) {
    // Try primary recommendation engine
    recs, err := s.primaryEngine.GetRecommendations(ctx, userID)
    if err == nil {
        return recs, nil
    }
    
    // Log the failure but continue with fallback
    s.logger.WarnContext(ctx, "Primary recommendation engine failed, using fallback",
        slog.String("user_id", userID),
        slog.String("error", err.Error()))
    
    // Try fallback engine
    fallbackRecs, fallbackErr := s.fallbackEngine.GetRecommendations(ctx, userID)
    if fallbackErr == nil {
        return fallbackRecs, nil
    }
    
    // Both failed - return error but with context
    return nil, &APIError{
        Code:       "RECOMMENDATIONS_UNAVAILABLE",
        Message:    "Unable to generate recommendations",
        StatusCode: http.StatusServiceUnavailable,
        Internal:   fmt.Errorf("both engines failed - primary: %w, fallback: %w", err, fallbackErr),
    }
}

Testing Error Scenarios

Comprehensive error testing ensures your patterns work under pressure:

func TestUserHandler_GetUser_NotFound(t *testing.T) {
    mockService := &MockUserService{}
    handler := &UserHandler{service: mockService}
    
    mockService.On("GetUser", mock.Anything, "123").Return(nil, &APIError{
        Code:       "USER_NOT_FOUND",
        Message:    "User not found",
        StatusCode: http.StatusNotFound,
    })
    
    req := httptest.NewRequest("GET", "/users/123", nil)
    w := httptest.NewRecorder()
    
    err := handler.GetUser(w, req)
    
    assert.Error(t, err)
    
    var apiErr *APIError
    assert.True(t, errors.As(err, &apiErr))
    assert.Equal(t, "USER_NOT_FOUND", apiErr.Code)
    assert.Equal(t, http.StatusNotFound, apiErr.StatusCode)
}

Performance Considerations

Error handling shouldn’t kill performance. Avoid expensive operations in error paths:

// Bad: Expensive stack trace generation on every error
func badErrorHandler(err error) {
    stack := debug.Stack()
    log.Printf("Error with stack: %s\n%s", err, stack)
}

// Good: Only generate stack traces for serious errors
func goodErrorHandler(err error) {
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        if apiErr.StatusCode >= 500 {
            // Only capture stack for server errors
            stack := debug.Stack()
            log.Printf("Server error: %s\nStack: %s", err, stack)
        } else {
            log.Printf("Client error: %s", err)
        }
    }
}

Building Resilient Go APIs

These patterns transform basic error handling into a robust system that supports production operations. The key principles are:

  • Structure your errors with codes, messages, and metadata
  • Wrap errors to preserve context while adding layers of meaning
  • Centralize error handling to ensure consistent responses
  • Integrate with observability for debugging and monitoring
  • Plan for failures with circuit breakers and graceful degradation
  • Test error scenarios as thoroughly as happy paths

Go’s approach to treating errors as values and encouraging explicit handling makes these patterns natural and maintainable. Your APIs will be more reliable, your debugging sessions shorter, and your users happier with clear, actionable error messages.

The investment in sophisticated error handling pays dividends when issues arise in production. Your future self—and your operations team—will thank you for building systems that fail gracefully and provide the information needed to resolve problems quickly.

Reader settings

Font size