← Alle Beiträge

Go Error Handling: Erweiterte Patterns für Production APIs

Matthias Bruns · · 10 Min. Lesezeit
Go Backend API Design Error Handling

Error Handling in Go unterscheidet sich grundlegend von anderen Sprachen – es gibt keine Exceptions zum Abfangen, nur Werte zum Prüfen. Während dieser explizite Ansatz versteckte Kontrollflüsse verhindert, bedeutet es auch, dass Ihre Production APIs ausgeklügelte Error Handling Strategien benötigen, um Clients sinnvolles Feedback zu geben und gleichzeitig die System-Observability zu gewährleisten.

Die meisten Entwickler beginnen mit einfachen if err != nil Prüfungen, aber Production APIs verlangen mehr. Sie brauchen strukturierte Error-Responses, ordentlichen Logging-Kontext und Error Handling, das über Microservices hinweg skaliert. Dieser Guide erkundet erweiterte Patterns, die über grundlegende Error-Prüfungen hinausgehen und robustes, wartbares Error Handling für Go Backend-Systeme aufbauen.

Die Grundlage: Go’s Error-Philosophie verstehen

Go’s Error Handling Design behandelt Errors als Werte und fördert explizite Behandlung durch Return-Types. Die Sprache bietet mehrere eingebaute Funktionen wie errors.Is, errors.As und errors.Join, um Error-Inspektion und -Klassifizierung mächtiger zu gestalten.

type error interface {
    Error() string
}

Dieses einfache Interface ist die Grundlage von Go’s Error-System. Aber für Production APIs müssen Sie auf dieser Grundlage mit Custom Error Types aufbauen, die zusätzlichen Kontext transportieren.

Custom Error Types für API-Kontext

Einfache String-Errors bieten nicht genug Informationen für API-Responses. Custom Error Types erlauben es Ihnen, HTTP-Status-Codes, Error-Kategorien und benutzerfreundliche Nachrichten neben technischen Details einzubetten.

type APIError struct {
    Code       string    `json:"code"`
    Message    string    `json:"message"`
    StatusCode int       `json:"-"`
    Timestamp  time.Time `json:"timestamp"`
    RequestID  string    `json:"request_id,omitempty"`
    Details    any       `json:"details,omitempty"`
}

func (e APIError) Error() string {
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

func NewAPIError(code string, message string, statusCode int) APIError {
    return APIError{
        Code:       code,
        Message:    message,
        StatusCode: statusCode,
        Timestamp:  time.Now(),
    }
}

Diese Struktur bietet alles Nötige für konsistente API-Responses und behält gleichzeitig die technische Error-Message für das Logging bei.

Sentinel Errors für Entscheidungsfindung

Sentinel Errors sind vordefinierte Error-Werte, die Ihrem Code erlauben, Entscheidungen basierend auf spezifischen Error-Bedingungen zu treffen. Sie sind besonders nützlich in APIs, wo verschiedene Error-Typen unterschiedliche Behandlung erfordern.

var (
    ErrUserNotFound     = NewAPIError("USER_NOT_FOUND", "User not found", http.StatusNotFound)
    ErrInvalidInput     = NewAPIError("INVALID_INPUT", "Invalid input provided", http.StatusBadRequest)
    ErrUnauthorized     = NewAPIError("UNAUTHORIZED", "Authentication required", http.StatusUnauthorized)
    ErrRateLimited      = NewAPIError("RATE_LIMITED", "Too many requests", http.StatusTooManyRequests)
    ErrInternalServer   = NewAPIError("INTERNAL_ERROR", "Internal server error", http.StatusInternalServerError)
)

func GetUser(id string) (*User, error) {
    if id == "" {
        return nil, ErrInvalidInput
    }
    
    user, err := userRepo.FindByID(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("database error: %w", err)
    }
    
    return user, nil
}

Die Verwendung von Sentinel Errors macht Ihr Error Handling vorhersagbar und testbar. Clients wissen genau, welche Error-Codes sie erwarten können, und Ihre Middleware kann jeden Typ entsprechend behandeln.

Error Wrapping und Kontext-Bewahrung

Go’s fmt.Errorf mit dem %w Verb ermöglicht es Ihnen, Errors zu wrappen und dabei den ursprünglichen Error für die Inspektion zu bewahren. Das ist entscheidend für die Bewahrung des Error-Kontexts, während er durch Ihre Anwendungsschichten nach oben wandert.

func (s *UserService) CreateUser(req CreateUserRequest) (*User, error) {
    // Input validieren
    if err := req.Validate(); err != nil {
        return nil, fmt.Errorf("validation failed: %w", ErrInvalidInput)
    }
    
    // Prüfen ob User existiert
    existing, err := s.repo.FindByEmail(req.Email)
    if err != nil && !errors.Is(err, ErrUserNotFound) {
        return nil, fmt.Errorf("checking existing user: %w", err)
    }
    
    if existing != nil {
        return nil, fmt.Errorf("user already exists: %w", ErrInvalidInput)
    }
    
    // User erstellen
    user, err := s.repo.Create(req)
    if err != nil {
        return nil, fmt.Errorf("creating user in database: %w", err)
    }
    
    return user, nil
}

Die gewrappten Errors behalten die Kontextkette bei und erlauben gleichzeitig die Verwendung von errors.Is und errors.As zur Inspektion der zugrundeliegenden Error-Typen.

Zentralisiertes HTTP Error Handling

Anstatt Errors in jedem HTTP Handler zu behandeln, zentralisieren Sie das Error Handling in Middleware. Das gewährleistet konsistente Error-Responses und reduziert Code-Duplikation über Ihre API-Endpoints hinweg.

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

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

func handleAPIError(w http.ResponseWriter, r *http.Request, err error) {
    var apiErr APIError
    
    // Prüfen ob es bereits ein APIError ist
    if errors.As(err, &apiErr) {
        apiErr.RequestID = getRequestID(r)
        writeJSONError(w, apiErr.StatusCode, apiErr)
        logError(r.Context(), err, apiErr.StatusCode)
        return
    }
    
    // Gewrappte Errors behandeln
    if errors.Is(err, ErrUserNotFound) {
        apiErr = ErrUserNotFound
    } else if errors.Is(err, ErrInvalidInput) {
        apiErr = ErrInvalidInput
    } else {
        // Unbekannter Error - vollständige Details loggen aber generische Message zurückgeben
        logError(r.Context(), err, http.StatusInternalServerError)
        apiErr = ErrInternalServer
    }
    
    apiErr.RequestID = getRequestID(r)
    writeJSONError(w, apiErr.StatusCode, apiErr)
}

func writeJSONError(w http.ResponseWriter, statusCode int, apiErr APIError) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(statusCode)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "error": apiErr,
    })
}

Jetzt können sich Ihre Handler auf die Business Logic konzentrieren, während das Error Handling konsistent bleibt:

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 fmt.Errorf("invalid JSON: %w", ErrInvalidInput)
    }
    
    user, err := h.service.CreateUser(req)
    if err != nil {
        return err // Den Error Handler damit umgehen lassen
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    return json.NewEncoder(w).Encode(user)
}

Strukturiertes Logging für Error-Kontext

Production APIs benötigen strukturiertes Logging, das Kontext für das Debugging bietet und gleichzeitig die Performance aufrechterhält. Verwenden Sie strukturierte Logging-Libraries wie slog (Go 1.21+) oder logrus, um Error-Kontext systematisch zu erfassen.

func logError(ctx context.Context, err error, statusCode int) {
    logger := slog.Default()
    
    // Request-Kontext extrahieren
    requestID := getRequestID(ctx)
    userID := getUserID(ctx)
    
    // Log-Level basierend auf Error-Typ bestimmen
    logLevel := slog.LevelError
    if statusCode < 500 {
        logLevel = slog.LevelWarn
    }
    
    logger.LogAttrs(
        ctx,
        logLevel,
        "API error occurred",
        slog.String("request_id", requestID),
        slog.String("user_id", userID),
        slog.Int("status_code", statusCode),
        slog.String("error", err.Error()),
        slog.String("error_type", fmt.Sprintf("%T", err)),
    )
    
    // Für interne Errors den vollständigen Stack Trace loggen
    if statusCode >= 500 {
        logger.LogAttrs(
            ctx,
            slog.LevelError,
            "Internal error details",
            slog.String("request_id", requestID),
            slog.String("stack_trace", getStackTrace(err)),
        )
    }
}

Dieser Ansatz gibt Ihnen durchsuchbare Logs mit konsistenter Struktur und vermeidet Log-Spam von Client-Errors.

Validation Error Aggregation

APIs sollten alle Validierungsfehler auf einmal zurückgeben, anstatt beim ersten Problem zu scheitern. Das verbessert die User Experience und reduziert Round Trips.

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

type ValidationErrors struct {
    Errors []ValidationError `json:"errors"`
}

func (ve ValidationErrors) Error() string {
    if len(ve.Errors) == 0 {
        return "validation failed"
    }
    return fmt.Sprintf("validation failed: %d errors", len(ve.Errors))
}

func (req *CreateUserRequest) Validate() error {
    var errors []ValidationError
    
    if req.Email == "" {
        errors = append(errors, ValidationError{
            Field:   "email",
            Message: "email is required",
        })
    } else if !isValidEmail(req.Email) {
        errors = append(errors, ValidationError{
            Field:   "email", 
            Message: "email format is invalid",
            Value:   req.Email,
        })
    }
    
    if len(req.Password) < 8 {
        errors = append(errors, ValidationError{
            Field:   "password",
            Message: "password must be at least 8 characters",
        })
    }
    
    if len(errors) > 0 {
        return ValidationErrors{Errors: errors}
    }
    
    return nil
}

Aktualisieren Sie Ihren Error Handler, um Validierungsfehler zu erkennen:

func handleAPIError(w http.ResponseWriter, r *http.Request, err error) {
    var validationErr ValidationErrors
    if errors.As(err, &validationErr) {
        apiErr := APIError{
            Code:      "VALIDATION_FAILED",
            Message:   "Request validation failed",
            StatusCode: http.StatusBadRequest,
            Timestamp: time.Now(),
            RequestID: getRequestID(r),
            Details:   validationErr.Errors,
        }
        writeJSONError(w, apiErr.StatusCode, apiErr)
        return
    }
    
    // ... Rest des Error Handlings
}

Circuit Breaker Pattern für externe Abhängigkeiten

Wenn Ihre API von externen Services abhängt, implementieren Sie Circuit Breaker Patterns, um Failures graceful zu behandeln und Cascade Failures zu verhindern.

type CircuitBreaker struct {
    maxFailures int
    timeout     time.Duration
    failures    int
    lastFailure time.Time
    state       string // "closed", "open", "half-open"
    mutex       sync.RWMutex
}

func (cb *CircuitBreaker) Call(fn func() error) error {
    cb.mutex.Lock()
    defer cb.mutex.Unlock()
    
    if cb.state == "open" {
        if time.Since(cb.lastFailure) > cb.timeout {
            cb.state = "half-open"
            cb.failures = 0
        } else {
            return fmt.Errorf("circuit breaker open: %w", ErrInternalServer)
        }
    }
    
    err := fn()
    if err != nil {
        cb.failures++
        cb.lastFailure = time.Now()
        
        if cb.failures >= cb.maxFailures {
            cb.state = "open"
        }
        
        return fmt.Errorf("external service error: %w", err)
    }
    
    if cb.state == "half-open" {
        cb.state = "closed"
    }
    cb.failures = 0
    
    return nil
}

Dieses Pattern verhindert, dass Ihre API wiederholt fehlschlagende externe Services aufruft und bietet gleichzeitig klare Error-Messages für Clients.

Error Handling in Microservices

In Microservices-Architekturen müssen Errors über Service-Grenzen hinweg propagiert werden, wobei der Kontext erhalten bleibt. Verwenden Sie Error-Codes, die über Services hinweg konsistent bleiben, und implementieren Sie ordentliches Timeout-Handling.

type ServiceError struct {
    Service    string    `json:"service"`
    Operation  string    `json:"operation"`
    Code       string    `json:"code"`
    Message    string    `json:"message"`
    Timestamp  time.Time `json:"timestamp"`
    TraceID    string    `json:"trace_id"`
}

func (se ServiceError) Error() string {
    return fmt.Sprintf("[%s:%s] %s: %s", se.Service, se.Operation, se.Code, se.Message)
}

func CallUserService(ctx context.Context, userID string) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    
    resp, err := http.Get(fmt.Sprintf("http://user-service/users/%s", userID))
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, ServiceError{
                Service:   "user-service",
                Operation: "get_user",
                Code:      "TIMEOUT",
                Message:   "Request timed out",
                Timestamp: time.Now(),
                TraceID:   getTraceID(ctx),
            }
        }
        
        return nil, fmt.Errorf("user service call failed: %w", err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        return nil, ServiceError{
            Service:   "user-service", 
            Operation: "get_user",
            Code:      "HTTP_ERROR",
            Message:   fmt.Sprintf("HTTP %d", resp.StatusCode),
            Timestamp: time.Now(),
            TraceID:   getTraceID(ctx),
        }
    }
    
    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("decoding user response: %w", err)
    }
    
    return &user, nil
}

Performance-Überlegungen

Error Handling sollte die Performance Ihrer API nicht beeinträchtigen. Vermeiden Sie teure Operationen in Error-Pfaden und verwenden Sie effiziente Logging-Strategien.

// Machen Sie das nicht - teure String-Formatierung bei jedem Error
func badErrorHandling(err error) error {
    return fmt.Errorf("operation failed at %s with details: %+v and stack: %s", 
        time.Now().Format(time.RFC3339), err, debug.Stack())
}

// Machen Sie das - Lazy Evaluation und strukturiertes Logging
func goodErrorHandling(ctx context.Context, operation string, err error) error {
    // Strukturierte Daten einmal loggen
    slog.ErrorContext(ctx, "operation failed",
        slog.String("operation", operation),
        slog.String("error", err.Error()),
    )
    
    // Einfachen gewrappten Error zurückgeben
    return fmt.Errorf("%s failed: %w", operation, err)
}

Für APIs mit hohem Durchsatz erwägen Sie Error Sampling, um das Log-Volumen zu reduzieren und gleichzeitig die Sichtbarkeit in Error-Patterns zu erhalten.

Error Handling testen

Ihre Error Handling Patterns benötigen umfassende Tests, um sicherzustellen, dass sie unter verschiedenen Failure-Bedingungen korrekt funktionieren.

func TestUserService_CreateUser_ValidationErrors(t *testing.T) {
    service := NewUserService(mockRepo)
    
    tests := []struct {
        name     string
        request  CreateUserRequest
        wantErrs []string // Erwartete Validierungsfehler-Felder
    }{
        {
            name:     "missing email and short password",
            request:  CreateUserRequest{Password: "123"},
            wantErrs: []string{"email", "password"},
        },
        {
            name:     "invalid email format",
            request:  CreateUserRequest{Email: "invalid", Password: "password123"},
            wantErrs: []string{"email"},
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := service.CreateUser(tt.request)
            
            var validationErr ValidationErrors
            if !errors.As(err, &validationErr) {
                t.Fatalf("expected ValidationErrors, got %T", err)
            }
            
            if len(validationErr.Errors) != len(tt.wantErrs) {
                t.Errorf("expected %d validation errors, got %d", 
                    len(tt.wantErrs), len(validationErr.Errors))
            }
            
            for _, field := range tt.wantErrs {
                found := false
                for _, verr := range validationErr.Errors {
                    if verr.Field == field {
                        found = true
                        break
                    }
                }
                if !found {
                    t.Errorf("expected validation error for field %s", field)
                }
            }
        })
    }
}

Monitoring und Alerting

Production Error Handling erfordert ordentliches Monitoring. Verfolgen Sie Error-Raten, Response-Zeiten und Error-Patterns, um Probleme zu identifizieren, bevor sie Benutzer beeinträchtigen.

type ErrorMetrics struct {
    errorCounter   *prometheus.CounterVec
    responseTime   *prometheus.HistogramVec
}

func NewErrorMetrics() *ErrorMetrics {
    return &ErrorMetrics{
        errorCounter: prometheus.NewCounterVec(
            prometheus.CounterOpts{
                Name: "api_errors_total",
                Help: "Total number of API errors by type and status code",
            },
            []string{"error_code", "status_code", "endpoint"},
        ),
        responseTime: prometheus.NewHistogramVec(
            prometheus.HistogramOpts{
                Name: "api_request_duration_seconds",
                Help: "API request duration by endpoint and status",
            },
            []string{"endpoint", "status_code"},
        ),
    }
}

func (em *ErrorMetrics) RecordError(endpoint, errorCode string, statusCode int) {
    em.errorCounter.WithLabelValues(
        errorCode, 
        strconv.Itoa(statusCode), 
        endpoint,
    ).Inc()
}

Richten Sie Alerts für Error-Rate-Spitzen ein, besonders für 5xx Errors, und verfolgen Sie Error-Patterns, um systemische Probleme zu identifizieren.

Resilientes Error Handling aufbauen

Erweiterte Error Handling in der Go Backend-Entwicklung erfordert Denken über einzelne Errors hinaus hin zu systemweiter Resilienz. Die hier behandelten Patterns – Custom Error Types, zentralisiertes Handling, strukturiertes Logging und ordentliches Testen – bilden die Grundlage für wartbares Error Handling, das mit Ihrer Microservices-Architektur skaliert.

Der Schlüssel ist Konsistenz: Etablieren Sie Error Handling Patterns früh und wenden Sie sie einheitlich über Ihre Codebase hinweg an. Ihre APIs werden zuverlässiger sein, Ihre Debugging-Sessions produktiver und Ihre Benutzer werden eine bessere Erfahrung haben, wenn Dinge unvermeidlich schief gehen.

Denken Sie daran, dass gutes Error Handling unsichtbar ist, wenn es funktioniert, und unbezahlbar, wenn es das nicht tut. Investieren Sie früh in diese Patterns, und Ihre Production-Systeme werden es Ihnen später danken.

Lesebarkeit

Schriftgröße