← All Posts

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

Matthias Bruns · · 8 min read
Go API Design Backend Development Error Handling

Production APIs demand bulletproof error handling. While Go’s explicit error handling is a strength, most developers stop at basic if err != nil checks. That’s not enough when your API serves thousands of requests per minute and debugging becomes a nightmare without proper error context.

This guide covers advanced error handling patterns that separate production-ready Go APIs from hobby projects. We’ll explore structured errors, proper error wrapping, and observability integration that actually helps you sleep at night.

The Problem with Basic Error Returns

Go’s standard error handling encourages explicit checks, but basic implementations fall short in production environments. Consider this typical API handler:

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    userID := r.URL.Query().Get("id")
    
    user, err := h.userService.GetByID(userID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    json.NewEncoder(w).Encode(user)
}

This approach has several critical flaws:

  1. Information leakage: Database errors, file paths, or internal service details leak to clients
  2. Poor observability: No structured logging or error categorization
  3. Inconsistent responses: Different error formats across endpoints
  4. Lost context: No way to trace errors back to their origin

According to JetBrains’ secure error handling guide, you should “wrap your errors in generic messages when crossing public boundaries, like from your public API gateway to the end user.”

Structured Error Types

The foundation of robust error handling starts with structured error types that carry context without exposing internals.

type APIError struct {
    Code       string            `json:"code"`
    Message    string            `json:"message"`
    Details    map[string]string `json:"details,omitempty"`
    HTTPStatus int               `json:"-"`
    Internal   error             `json:"-"`
    TraceID    string            `json:"trace_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 information from internal details. The Internal field stores the original error for logging, while Code and Message provide consistent client responses.

Create constructor functions for common error patterns:

func NewValidationError(field, reason string) *APIError {
    return &APIError{
        Code:       "VALIDATION_ERROR",
        Message:    "Request validation failed",
        Details:    map[string]string{field: reason},
        HTTPStatus: http.StatusBadRequest,
    }
}

func NewNotFoundError(resource string) *APIError {
    return &APIError{
        Code:       "NOT_FOUND",
        Message:    fmt.Sprintf("%s not found", resource),
        HTTPStatus: http.StatusNotFound,
    }
}

func NewInternalError(err error) *APIError {
    return &APIError{
        Code:       "INTERNAL_ERROR",
        Message:    "Service temporarily unavailable",
        HTTPStatus: http.StatusInternalServerError,
        Internal:   err,
    }
}

Error Wrapping and Context Preservation

Go 1.13 introduced errors.Wrap and errors.Unwrap for adding context while preserving the original error. As noted in Earthly’s error handling guide, these APIs are “useful in applying additional context to an error as it ‘bubbles up’, as well as checking for particular error types.”

Here’s how to implement proper error wrapping in your service layer:

type UserService struct {
    repo UserRepository
    logger *slog.Logger
}

func (s *UserService) GetByID(ctx context.Context, id string) (*User, error) {
    if id == "" {
        return nil, NewValidationError("id", "cannot be empty")
    }
    
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, NewNotFoundError("User")
        }
        
        // Wrap database errors to preserve context
        return nil, fmt.Errorf("failed to fetch user %s: %w", id, err)
    }
    
    return user, nil
}

The key is wrapping errors at each layer while maintaining the ability to check for specific error types using errors.Is and errors.As.

HTTP Handler Error Patterns

Transform your HTTP handlers to use structured errors consistently. Create a custom handler type that automatically handles error responses:

type APIHandler func(w http.ResponseWriter, r *http.Request) error

func (h APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    err := h(w, r)
    if err != nil {
        handleAPIError(w, r, err)
    }
}

func handleAPIError(w http.ResponseWriter, r *http.Request, err error) {
    var apiErr *APIError
    
    if errors.As(err, &apiErr) {
        // Log internal details
        if apiErr.Internal != nil {
            slog.Error("API error", 
                "error", apiErr.Internal,
                "code", apiErr.Code,
                "path", r.URL.Path,
                "method", r.Method,
            )
        }
        
        // Respond with safe, structured error
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(apiErr.HTTPStatus)
        json.NewEncoder(w).Encode(apiErr)
        return
    }
    
    // Handle unexpected errors
    slog.Error("Unexpected error", "error", err, "path", r.URL.Path)
    
    genericErr := &APIError{
        Code:       "INTERNAL_ERROR",
        Message:    "Service temporarily unavailable",
        HTTPStatus: http.StatusInternalServerError,
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusInternalServerError)
    json.NewEncoder(w).Encode(genericErr)
}

This pattern, inspired by Go’s official error handling blog, ensures consistent error responses while logging detailed information for debugging.

Now your handlers become cleaner and more focused:

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) error {
    userID := r.URL.Query().Get("id")
    
    user, err := h.userService.GetByID(r.Context(), userID)
    if err != nil {
        return err // Let the wrapper handle it
    }
    
    w.Header().Set("Content-Type", "application/json")
    return json.NewEncoder(w).Encode(user)
}

// Register with custom handler
http.Handle("/users", APIHandler(userHandler.GetUser))

Observability Integration

Production APIs need comprehensive error tracking. Integrate structured logging and distributed tracing into your error handling:

type ObservableError struct {
    *APIError
    SpanID  string
    TraceID string
    UserID  string
    Metrics map[string]interface{}
}

func (e *ObservableError) LogStructured(logger *slog.Logger) {
    attrs := []slog.Attr{
        slog.String("error_code", e.Code),
        slog.String("trace_id", e.TraceID),
        slog.String("span_id", e.SpanID),
    }
    
    if e.UserID != "" {
        attrs = append(attrs, slog.String("user_id", e.UserID))
    }
    
    if e.Internal != nil {
        attrs = append(attrs, slog.String("internal_error", e.Internal.Error()))
    }
    
    for k, v := range e.Metrics {
        attrs = append(attrs, slog.Any(k, v))
    }
    
    logger.LogAttrs(context.Background(), slog.LevelError, e.Message, attrs...)
}

Create middleware that automatically adds observability context:

func ObservabilityMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Extract or generate trace ID
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = generateTraceID()
        }
        
        // Add to response headers
        w.Header().Set("X-Trace-ID", traceID)
        
        // Store in context
        ctx := context.WithValue(r.Context(), "trace_id", traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Error Recovery and Circuit Breaking

Implement graceful degradation when downstream services fail:

type CircuitBreaker struct {
    maxFailures int
    resetTime   time.Duration
    failures    int
    lastFailure time.Time
    mu          sync.RWMutex
}

func (cb *CircuitBreaker) Call(ctx context.Context, fn func() error) error {
    cb.mu.RLock()
    if cb.failures >= cb.maxFailures {
        if time.Since(cb.lastFailure) < cb.resetTime {
            cb.mu.RUnlock()
            return NewServiceUnavailableError("Circuit breaker open")
        }
    }
    cb.mu.RUnlock()
    
    err := fn()
    
    cb.mu.Lock()
    defer cb.mu.Unlock()
    
    if err != nil {
        cb.failures++
        cb.lastFailure = time.Now()
        return fmt.Errorf("circuit breaker recorded failure: %w", err)
    }
    
    cb.failures = 0
    return nil
}

func NewServiceUnavailableError(reason string) *APIError {
    return &APIError{
        Code:       "SERVICE_UNAVAILABLE",
        Message:    "Service temporarily unavailable",
        Details:    map[string]string{"reason": reason},
        HTTPStatus: http.StatusServiceUnavailable,
    }
}

Validation Error Patterns

Handle input validation consistently across your API:

type ValidationErrors []FieldError

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

func (ve ValidationErrors) Error() string {
    var messages []string
    for _, err := range ve {
        messages = append(messages, fmt.Sprintf("%s: %s", err.Field, err.Message))
    }
    return strings.Join(messages, "; ")
}

func (ve ValidationErrors) ToAPIError() *APIError {
    details := make(map[string]string)
    for _, err := range ve {
        details[err.Field] = err.Message
    }
    
    return &APIError{
        Code:       "VALIDATION_ERROR",
        Message:    "Request validation failed",
        Details:    details,
        HTTPStatus: http.StatusBadRequest,
    }
}

func ValidateUser(user *User) error {
    var errors ValidationErrors
    
    if user.Email == "" {
        errors = append(errors, FieldError{
            Field:   "email",
            Message: "email is required",
        })
    } else if !isValidEmail(user.Email) {
        errors = append(errors, FieldError{
            Field:   "email",
            Message: "invalid email format",
            Value:   user.Email,
        })
    }
    
    if len(errors) > 0 {
        return errors
    }
    
    return nil
}

Testing Error Scenarios

Test your error handling patterns thoroughly:

func TestUserHandler_GetUser_NotFound(t *testing.T) {
    mockService := &MockUserService{
        GetByIDFunc: func(ctx context.Context, id string) (*User, error) {
            return nil, NewNotFoundError("User")
        },
    }
    
    handler := &UserHandler{userService: mockService}
    
    req := httptest.NewRequest("GET", "/users?id=123", nil)
    rec := httptest.NewRecorder()
    
    err := handler.GetUser(rec, req)
    
    var apiErr *APIError
    require.True(t, errors.As(err, &apiErr))
    assert.Equal(t, "NOT_FOUND", apiErr.Code)
    assert.Equal(t, http.StatusNotFound, apiErr.HTTPStatus)
}

func TestErrorHandling_InternalError_DoesNotLeakDetails(t *testing.T) {
    internalErr := errors.New("database connection failed: connection refused")
    apiErr := NewInternalError(internalErr)
    
    // Public message should be generic
    assert.Equal(t, "Service temporarily unavailable", apiErr.Message)
    assert.Equal(t, "INTERNAL_ERROR", apiErr.Code)
    
    // Internal error preserved for logging
    assert.Equal(t, internalErr, apiErr.Internal)
}

Performance Considerations

Error handling shouldn’t impact performance. Avoid these common pitfalls:

// Bad: Creating stack traces on every error
func badErrorHandling() error {
    return fmt.Errorf("error with stack: %s", debug.Stack())
}

// Good: Only capture stack traces for unexpected errors
func goodErrorHandling() error {
    return NewValidationError("field", "invalid")
}

// Bad: Expensive string formatting in error paths
func expensiveError(userID int, data []byte) error {
    return fmt.Errorf("failed to process user %d with data: %s", userID, string(data))
}

// Good: Lazy error formatting
func efficientError(userID int) error {
    return &APIError{
        Code:    "PROCESSING_FAILED",
        Message: "Failed to process user data",
        Details: map[string]string{"user_id": strconv.Itoa(userID)},
    }
}

Production Deployment Patterns

Configure error handling for different environments:

type ErrorConfig struct {
    IncludeStackTrace bool
    LogLevel          slog.Level
    SentryDSN         string
}

func NewErrorHandler(config ErrorConfig) *ErrorHandler {
    return &ErrorHandler{
        config: config,
        logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
            Level: config.LogLevel,
        })),
    }
}

func (h *ErrorHandler) HandleError(ctx context.Context, err error) *APIError {
    var apiErr *APIError
    if !errors.As(err, &apiErr) {
        apiErr = NewInternalError(err)
    }
    
    // Add trace ID from context
    if traceID, ok := ctx.Value("trace_id").(string); ok {
        apiErr.TraceID = traceID
    }
    
    // Log with appropriate detail level
    if h.config.IncludeStackTrace && apiErr.Internal != nil {
        h.logger.Error("API error with stack",
            "error", apiErr.Internal,
            "stack", string(debug.Stack()),
            "trace_id", apiErr.TraceID,
        )
    }
    
    return apiErr
}

Conclusion

Robust error handling transforms your Go APIs from fragile prototypes into production-ready services. The patterns covered here—structured errors, proper wrapping, observability integration, and security-conscious responses—form the foundation of reliable backend systems.

As Datadog’s practical guide emphasizes, “Go’s design treats errors as values and encourages explicit handling through return types, wrapping, and custom error types.” Embrace this philosophy, but extend it with the production patterns that make debugging and monitoring actually useful.

The investment in proper error handling pays dividends when you’re debugging a production incident at 2 AM. Your future self will thank you for the structured logs, clear error codes, and preserved context that make root cause analysis possible instead of painful.

Start with structured error types, implement consistent HTTP error responses, and gradually add observability features. Your APIs—and your operations team—will be better for it.

Reader settings

Font size