← Alle Beiträge

Go Error Handling Patterns für Produktions-APIs: Jenseits einfacher Error Returns

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

Produktions-APIs benötigen kugelsichere Fehlerbehandlung. Während Go’s explizite Fehlerbehandlung eine Stärke ist, bleiben die meisten Entwickler bei einfachen if err != nil-Checks stehen. Das reicht nicht aus, wenn Ihre API tausende Requests pro Minute bedient und das Debugging ohne ordentlichen Fehlerkontext zum Albtraum wird.

Dieser Guide behandelt fortgeschrittene Error Handling Patterns, die produktionsreife Go APIs von Hobbyprojekten unterscheiden. Wir erkunden strukturierte Fehler, ordentliches Error Wrapping und Observability-Integration, die Ihnen tatsächlich dabei hilft, nachts zu schlafen.

Das Problem mit einfachen Error Returns

Go’s Standard-Fehlerbehandlung fördert explizite Checks, aber grundlegende Implementierungen sind in Produktionsumgebungen unzureichend. Betrachten Sie diesen typischen 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)
}

Dieser Ansatz hat mehrere kritische Schwächen:

  1. Informationsleckage: Datenbankfehler, Dateipfade oder interne Service-Details gelangen zu Clients
  2. Schlechte Observability: Kein strukturiertes Logging oder Fehlerkategorisierung
  3. Inkonsistente Antworten: Verschiedene Fehlerformate über Endpoints hinweg
  4. Verlorener Kontext: Keine Möglichkeit, Fehler zu ihrem Ursprung zurückzuverfolgen

Laut JetBrains’ Secure Error Handling Guide sollten Sie “Ihre Fehler in generischen Nachrichten wrappen, wenn sie öffentliche Grenzen überschreiten, wie von Ihrem öffentlichen API Gateway zum Endbenutzer.”

Strukturierte Fehlertypen

Das Fundament robuster Fehlerbehandlung beginnt mit strukturierten Fehlertypen, die Kontext transportieren, ohne Interna preiszugeben.

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
}

Diese Struktur trennt öffentliche Informationen von internen Details. Das Internal-Feld speichert den ursprünglichen Fehler fürs Logging, während Code und Message konsistente Client-Antworten liefern.

Erstellen Sie Konstruktor-Funktionen für häufige Fehlermuster:

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 und Kontext-Bewahrung

Go 1.13 führte errors.Wrap und errors.Unwrap ein, um Kontext hinzuzufügen und dabei den ursprünglichen Fehler zu bewahren. Wie in Earthly’s Error Handling Guide betont wird, sind diese APIs “nützlich, um zusätzlichen Kontext zu einem Fehler hinzuzufügen, während er ‘nach oben blubbert’, sowie um nach bestimmten Fehlertypen zu suchen.”

So implementieren Sie ordentliches Error Wrapping in Ihrer Service-Schicht:

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")
        }
        
        // Datenbank-Fehler wrappen, um Kontext zu bewahren
        return nil, fmt.Errorf("failed to fetch user %s: %w", id, err)
    }
    
    return user, nil
}

Der Schlüssel ist, Fehler auf jeder Schicht zu wrappen und dabei die Fähigkeit zu erhalten, spezifische Fehlertypen mit errors.Is und errors.As zu prüfen.

HTTP Handler Error Patterns

Transformieren Sie Ihre HTTP-Handler, um strukturierte Fehler konsistent zu verwenden. Erstellen Sie einen benutzerdefinierten Handler-Typ, der automatisch Fehlerantworten behandelt:

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) {
        // Interne Details loggen
        if apiErr.Internal != nil {
            slog.Error("API error", 
                "error", apiErr.Internal,
                "code", apiErr.Code,
                "path", r.URL.Path,
                "method", r.Method,
            )
        }
        
        // Mit sicherem, strukturiertem Fehler antworten
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(apiErr.HTTPStatus)
        json.NewEncoder(w).Encode(apiErr)
        return
    }
    
    // Unerwartete Fehler behandeln
    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)
}

Dieses Pattern, inspiriert von Go’s offiziellem Error Handling Blog, gewährleistet konsistente Fehlerantworten und loggt detaillierte Informationen zum Debugging.

Jetzt werden Ihre Handler sauberer und fokussierter:

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 // Lassen Sie den Wrapper es behandeln
    }
    
    w.Header().Set("Content-Type", "application/json")
    return json.NewEncoder(w).Encode(user)
}

// Mit benutzerdefiniertem Handler registrieren
http.Handle("/users", APIHandler(userHandler.GetUser))

Observability-Integration

Produktions-APIs benötigen umfassendes Error Tracking. Integrieren Sie strukturiertes Logging und Distributed Tracing in Ihre Fehlerbehandlung:

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...)
}

Erstellen Sie Middleware, die automatisch Observability-Kontext hinzufügt:

func ObservabilityMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Trace ID extrahieren oder generieren
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = generateTraceID()
        }
        
        // Zu Response-Headern hinzufügen
        w.Header().Set("X-Trace-ID", traceID)
        
        // Im Kontext speichern
        ctx := context.WithValue(r.Context(), "trace_id", traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Error Recovery und Circuit Breaking

Implementieren Sie graceful Degradation, wenn nachgelagerte Services ausfallen:

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

Behandeln Sie Input-Validierung konsistent über Ihre API hinweg:

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 von Fehlerszenarien

Testen Sie Ihre Error Handling Patterns gründlich:

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)
    
    // Öffentliche Nachricht sollte generisch sein
    assert.Equal(t, "Service temporarily unavailable", apiErr.Message)
    assert.Equal(t, "INTERNAL_ERROR", apiErr.Code)
    
    // Interner Fehler fürs Logging bewahrt
    assert.Equal(t, internalErr, apiErr.Internal)
}

Performance-Überlegungen

Fehlerbehandlung sollte die Performance nicht beeinträchtigen. Vermeiden Sie diese häufigen Fallstricke:

// Schlecht: Stack Traces bei jedem Fehler erstellen
func badErrorHandling() error {
    return fmt.Errorf("error with stack: %s", debug.Stack())
}

// Gut: Stack Traces nur bei unerwarteten Fehlern erfassen
func goodErrorHandling() error {
    return NewValidationError("field", "invalid")
}

// Schlecht: Aufwändige String-Formatierung in Fehlerpfaden
func expensiveError(userID int, data []byte) error {
    return fmt.Errorf("failed to process user %d with data: %s", userID, string(data))
}

// Gut: Lazy Error-Formatierung
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)},
    }
}

Produktions-Deployment Patterns

Konfigurieren Sie Fehlerbehandlung für verschiedene Umgebungen:

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)
    }
    
    // Trace ID aus Kontext hinzufügen
    if traceID, ok := ctx.Value("trace_id").(string); ok {
        apiErr.TraceID = traceID
    }
    
    // Mit angemessenem Detailgrad loggen
    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
}

Fazit

Robuste Fehlerbehandlung verwandelt Ihre Go APIs von fragilen Prototypen in produktionsreife Services. Die hier behandelten Patterns—strukturierte Fehler, ordentliches Wrapping, Observability-Integration und sicherheitsbewusste Antworten—bilden das Fundament zuverlässiger Backend-Systeme.

Wie Datadog’s Practical Guide betont: “Go’s Design behandelt Fehler als Werte und fördert explizite Behandlung durch Return-Typen, Wrapping und benutzerdefinierte Fehlertypen.” Nehmen Sie diese Philosophie an, aber erweitern Sie sie mit den Produktions-Patterns, die Debugging und Monitoring tatsächlich nützlich machen.

Die Investition in ordentliche Fehlerbehandlung zahlt sich aus, wenn Sie um 2 Uhr morgens einen Produktions-Incident debuggen. Ihr zukünftiges Ich wird Ihnen für die strukturierten Logs, klaren Fehlercodes und bewahrten Kontext danken, die Root Cause Analysis möglich statt schmerzhaft machen.

Beginnen Sie mit strukturierten Fehlertypen, implementieren Sie konsistente HTTP-Fehlerantworten und fügen Sie schrittweise Observability-Features hinzu. Ihre APIs—und Ihr Operations-Team—werden davon profitieren.

Lesebarkeit

Schriftgröße