← Alle Beiträge

Go Error Handling Patterns für Microservices-Architektur

Matthias Bruns · · 11 Min. Lesezeit
Go microservices error-handling backend

Go’s Einfachheit und Performance machen es zu einer ausgezeichneten Wahl für Microservices-Architekturen, aber das Behandeln von Fehlern in verteilten Systemen bringt einzigartige Herausforderungen mit sich. Anders als bei monolithischen Anwendungen, wo Fehler in einem einzigen Kontext behandelt werden können, erfordern Microservices ausgeklügelte Error-Handling-Patterns, die die Systemresilienz aufrechterhalten und gleichzeitig aussagekräftige Debugging-Informationen bereitstellen.

In diesem Artikel erkunden wir fortgeschrittene Error-Handling-Patterns, die speziell für Go-Microservices entwickelt wurden, einschließlich Error-Wrapping-Strategien, Context-Propagation-Techniken und Distributed-Tracing-Integration. Diese Patterns helfen Ihnen dabei, robustere und wartbarere verteilte Systeme zu entwickeln.

Die Herausforderung des Error Handling in Microservices

Beim Entwickeln von Microservices mit Go treten Fehler nicht nur innerhalb einer einzelnen Service-Grenze auf. Sie propagieren über Netzwerk-Calls, durchlaufen mehrere Services und müssen auf verschiedenen Ebenen Ihrer Architektur behandelt werden. Die Herausforderung besteht darin, den Fehlerkontext zu erhalten und gleichzeitig sicherzustellen, dass jeder Service angemessene Entscheidungen basierend auf Fehlertypen treffen kann.

Traditionelles Go-Error-Handling mit einfachen Error-Returns wird unzureichend, wenn Sie folgendes benötigen:

  • Fehler über Service-Grenzen hinweg verfolgen
  • Fehlerkontext durch mehrere Netzwerk-Hops aufrechterhalten
  • Zwischen wiederholbaren und nicht-wiederholbaren Fehlern unterscheiden
  • Aussagekräftige Fehlerantworten für Clients bereitstellen und dabei interne Details verbergen

Strukturierte Fehlertypen für Microservices

Das Fundament effektiven Microservices-Error-Handlings beginnt mit strukturierten Fehlertypen. Anstatt einfache String-Errors zu verwenden, erstellen Sie Fehlertypen, die semantische Bedeutung tragen und von verschiedenen Services angemessen behandelt werden können.

package errors

import (
    "fmt"
    "net/http"
)

// ErrorCode repräsentiert verschiedene Fehlertypen in unserem System
type ErrorCode string

const (
    ErrorCodeValidation    ErrorCode = "VALIDATION_ERROR"
    ErrorCodeNotFound      ErrorCode = "NOT_FOUND"
    ErrorCodeUnauthorized  ErrorCode = "UNAUTHORIZED"
    ErrorCodeInternal      ErrorCode = "INTERNAL_ERROR"
    ErrorCodeServiceDown   ErrorCode = "SERVICE_UNAVAILABLE"
    ErrorCodeTimeout       ErrorCode = "TIMEOUT"
)

// ServiceError repräsentiert Fehler, die über Service-Grenzen hinweg behandelt werden können
type ServiceError struct {
    Code       ErrorCode `json:"code"`
    Message    string    `json:"message"`
    Service    string    `json:"service"`
    Operation  string    `json:"operation"`
    Retryable  bool      `json:"retryable"`
    StatusCode int       `json:"status_code"`
    Cause      error     `json:"-"` // Den zugrundeliegenden Fehler nicht serialisieren
}

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

func (e *ServiceError) Unwrap() error {
    return e.Cause
}

// HTTPStatusCode gibt den entsprechenden HTTP-Status-Code für diesen Fehler zurück
func (e *ServiceError) HTTPStatusCode() int {
    if e.StatusCode != 0 {
        return e.StatusCode
    }
    
    switch e.Code {
    case ErrorCodeValidation:
        return http.StatusBadRequest
    case ErrorCodeNotFound:
        return http.StatusNotFound
    case ErrorCodeUnauthorized:
        return http.StatusUnauthorized
    case ErrorCodeServiceDown, ErrorCodeTimeout:
        return http.StatusServiceUnavailable
    default:
        return http.StatusInternalServerError
    }
}

Dieser strukturierte Ansatz bietet mehrere Vorteile. Wie in Mario Carrions Microservices-Guide erwähnt, ermöglicht die Implementierung eines Fehlertyps mit Zustand, “einen Error Code zu definieren, den wir verwenden können, um verschiedene Antworten in unserer HTTP-Schicht ordnungsgemäß zu rendern.”

Error-Wrapping-Patterns

Go’s Error-Wrapping-Funktionalitäten, die in Go 1.13 eingeführt wurden, sind besonders mächtig in Microservices-Architekturen. Sie müssen jedoch strategisch vorgehen, welche Informationen Sie bewahren und welche Sie abstrahieren.

package userservice

import (
    "context"
    "fmt"
    "github.com/yourorg/common/errors"
)

type UserService struct {
    db     Database
    authSvc AuthService
}

func (s *UserService) GetUser(ctx context.Context, userID string) (*User, error) {
    // Input validieren
    if userID == "" {
        return nil, &errors.ServiceError{
            Code:      errors.ErrorCodeValidation,
            Message:   "Benutzer-ID darf nicht leer sein",
            Service:   "user-service",
            Operation: "GetUser",
            Retryable: false,
        }
    }
    
    // Autorisierung prüfen
    if err := s.authSvc.CheckPermission(ctx, userID); err != nil {
        var authErr *errors.ServiceError
        if errors.As(err, &authErr) {
            // Fehlercode bewahren, aber Kontext aktualisieren
            return nil, &errors.ServiceError{
                Code:      authErr.Code,
                Message:   "unzureichende Berechtigung für Benutzerzugriff",
                Service:   "user-service",
                Operation: "GetUser",
                Retryable: authErr.Retryable,
                Cause:     err,
            }
        }
        
        // Unbekannter Fehler vom Auth-Service
        return nil, &errors.ServiceError{
            Code:      errors.ErrorCodeInternal,
            Message:   "Autorisierungsprüfung fehlgeschlagen",
            Service:   "user-service",
            Operation: "GetUser",
            Retryable: false,
            Cause:     err,
        }
    }
    
    // Benutzer aus Datenbank abrufen
    user, err := s.db.GetUser(ctx, userID)
    if err != nil {
        return nil, s.wrapDatabaseError(err, "GetUser")
    }
    
    return user, nil
}

func (s *UserService) wrapDatabaseError(err error, operation string) error {
    // Auf spezifische Datenbankfehler prüfen
    if isNotFoundError(err) {
        return &errors.ServiceError{
            Code:      errors.ErrorCodeNotFound,
            Message:   "Benutzer nicht gefunden",
            Service:   "user-service",
            Operation: operation,
            Retryable: false,
            Cause:     err,
        }
    }
    
    if isTimeoutError(err) {
        return &errors.ServiceError{
            Code:      errors.ErrorCodeTimeout,
            Message:   "Datenbankoperation zeitüberschritten",
            Service:   "user-service",
            Operation: operation,
            Retryable: true,
            Cause:     err,
        }
    }
    
    // Generischer Datenbankfehler
    return &errors.ServiceError{
        Code:      errors.ErrorCodeInternal,
        Message:   "Datenbankoperation fehlgeschlagen",
        Service:   "user-service",
        Operation: operation,
        Retryable: false,
        Cause:     err,
    }
}

Context-Propagation für Error-Tracing

Context-Propagation ist entscheidend für das Verfolgen von Fehlern über Service-Grenzen hinweg. Sie müssen Trace-Informationen, Correlation-IDs und andere Metadaten übertragen, die beim Debugging verteilter Systeme helfen.

package middleware

import (
    "context"
    "net/http"
    "github.com/google/uuid"
)

type contextKey string

const (
    TraceIDKey      contextKey = "trace_id"
    CorrelationIDKey contextKey = "correlation_id"
    ServiceChainKey  contextKey = "service_chain"
)

// TraceMiddleware fügt Tracing-Informationen zu Requests hinzu
func TraceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        
        // Trace-ID extrahieren oder generieren
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = uuid.New().String()
        }
        ctx = context.WithValue(ctx, TraceIDKey, traceID)
        
        // Correlation-ID extrahieren oder generieren
        correlationID := r.Header.Get("X-Correlation-ID")
        if correlationID == "" {
            correlationID = uuid.New().String()
        }
        ctx = context.WithValue(ctx, CorrelationIDKey, correlationID)
        
        // Service-Chain aufbauen
        serviceChain := r.Header.Get("X-Service-Chain")
        if serviceChain != "" {
            serviceChain += " -> "
        }
        serviceChain += "user-service"
        ctx = context.WithValue(ctx, ServiceChainKey, serviceChain)
        
        // Trace-Header zur Response hinzufügen
        w.Header().Set("X-Trace-ID", traceID)
        w.Header().Set("X-Correlation-ID", correlationID)
        
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Erweiterte ServiceError mit Tracing-Informationen
type TracedServiceError struct {
    *errors.ServiceError
    TraceID       string `json:"trace_id"`
    CorrelationID string `json:"correlation_id"`
    ServiceChain  string `json:"service_chain"`
}

func NewTracedError(ctx context.Context, baseErr *errors.ServiceError) *TracedServiceError {
    traceID, _ := ctx.Value(TraceIDKey).(string)
    correlationID, _ := ctx.Value(CorrelationIDKey).(string)
    serviceChain, _ := ctx.Value(ServiceChainKey).(string)
    
    return &TracedServiceError{
        ServiceError:  baseErr,
        TraceID:       traceID,
        CorrelationID: correlationID,
        ServiceChain:  serviceChain,
    }
}

gRPC Error-Handling-Patterns

Bei der Verwendung von gRPC für Service-zu-Service-Kommunikation benötigen Sie spezifische Patterns, um Fehler über die Protokollgrenze hinweg zu behandeln. Die Reddit-Diskussion über gRPC-Error-Handling hebt häufige Herausforderungen und Lösungen hervor.

package grpchandler

import (
    "context"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    "google.golang.org/protobuf/types/known/anypb"
    "github.com/yourorg/common/errors"
)

// ErrorDetail repräsentiert zusätzliche Fehlerinformationen
type ErrorDetail struct {
    Code      string `json:"code"`
    Service   string `json:"service"`
    Operation string `json:"operation"`
    Retryable bool   `json:"retryable"`
}

// ToGRPCError konvertiert einen ServiceError zu einem gRPC-Status-Error
func ToGRPCError(err error) error {
    var serviceErr *errors.ServiceError
    if !errors.As(err, &serviceErr) {
        // Unbekannter Fehlertyp, als interner Fehler verpacken
        return status.Error(codes.Internal, "interner Serverfehler")
    }
    
    // Service-Error-Codes zu gRPC-Codes mappen
    var grpcCode codes.Code
    switch serviceErr.Code {
    case errors.ErrorCodeValidation:
        grpcCode = codes.InvalidArgument
    case errors.ErrorCodeNotFound:
        grpcCode = codes.NotFound
    case errors.ErrorCodeUnauthorized:
        grpcCode = codes.Unauthenticated
    case errors.ErrorCodeTimeout:
        grpcCode = codes.DeadlineExceeded
    case errors.ErrorCodeServiceDown:
        grpcCode = codes.Unavailable
    default:
        grpcCode = codes.Internal
    }
    
    // Status mit Details erstellen
    st := status.New(grpcCode, serviceErr.Message)
    
    // Fehlerdetails hinzufügen
    detail := &ErrorDetail{
        Code:      string(serviceErr.Code),
        Service:   serviceErr.Service,
        Operation: serviceErr.Operation,
        Retryable: serviceErr.Retryable,
    }
    
    detailAny, _ := anypb.New(detail)
    st, _ = st.WithDetails(detailAny)
    
    return st.Err()
}

// FromGRPCError konvertiert einen gRPC-Error zurück zu einem ServiceError
func FromGRPCError(err error) error {
    st, ok := status.FromError(err)
    if !ok {
        return &errors.ServiceError{
            Code:      errors.ErrorCodeInternal,
            Message:   "unbekannter gRPC-Fehler",
            Service:   "grpc-client",
            Operation: "call",
            Retryable: false,
            Cause:     err,
        }
    }
    
    // Fehlerdetails extrahieren falls verfügbar
    for _, detail := range st.Details() {
        if errorDetail, ok := detail.(*ErrorDetail); ok {
            return &errors.ServiceError{
                Code:      errors.ErrorCode(errorDetail.Code),
                Message:   st.Message(),
                Service:   errorDetail.Service,
                Operation: errorDetail.Operation,
                Retryable: errorDetail.Retryable,
                Cause:     err,
            }
        }
    }
    
    // Fallback auf gRPC-Code-Mapping
    var errorCode errors.ErrorCode
    switch st.Code() {
    case codes.InvalidArgument:
        errorCode = errors.ErrorCodeValidation
    case codes.NotFound:
        errorCode = errors.ErrorCodeNotFound
    case codes.Unauthenticated:
        errorCode = errors.ErrorCodeUnauthorized
    case codes.DeadlineExceeded:
        errorCode = errors.ErrorCodeTimeout
    case codes.Unavailable:
        errorCode = errors.ErrorCodeServiceDown
    default:
        errorCode = errors.ErrorCodeInternal
    }
    
    return &errors.ServiceError{
        Code:      errorCode,
        Message:   st.Message(),
        Service:   "unknown",
        Operation: "grpc-call",
        Retryable: st.Code() == codes.Unavailable || st.Code() == codes.DeadlineExceeded,
        Cause:     err,
    }
}

Circuit-Breaker-Integration

Circuit Breaker sind essentiell für die Verhinderung von Kaskaden-Ausfällen in Microservices-Architekturen. Ihre Integration mit Ihren Error-Handling-Patterns bietet automatische Fallback-Mechanismen.

package circuitbreaker

import (
    "context"
    "time"
    "github.com/sony/gobreaker"
    "github.com/yourorg/common/errors"
)

type ServiceClient struct {
    breaker *gobreaker.CircuitBreaker
    client  HTTPClient
}

func NewServiceClient(name string, client HTTPClient) *ServiceClient {
    settings := gobreaker.Settings{
        Name:        name,
        MaxRequests: 3,
        Interval:    10 * time.Second,
        Timeout:     30 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
            return counts.Requests >= 3 && failureRatio >= 0.6
        },
        OnStateChange: func(name string, from, to gobreaker.State) {
            // Statusänderungen für Monitoring protokollieren
            log.Printf("Circuit Breaker %s wechselte von %s zu %s", name, from, to)
        },
    }
    
    return &ServiceClient{
        breaker: gobreaker.NewCircuitBreaker(settings),
        client:  client,
    }
}

func (c *ServiceClient) Call(ctx context.Context, request *Request) (*Response, error) {
    result, err := c.breaker.Execute(func() (interface{}, error) {
        response, err := c.client.Do(ctx, request)
        if err != nil {
            // Prüfen ob dies ein wiederholbarer Fehler ist
            var serviceErr *errors.ServiceError
            if errors.As(err, &serviceErr) && !serviceErr.Retryable {
                // Nicht-wiederholbare Fehler sollten den Circuit Breaker nicht auslösen
                return nil, gobreaker.ErrIgnore{Err: err}
            }
        }
        return response, err
    })
    
    if err != nil {
        // Circuit-Breaker-spezifische Fehler behandeln
        if err == gobreaker.ErrOpenState {
            return nil, &errors.ServiceError{
                Code:      errors.ErrorCodeServiceDown,
                Message:   "Service temporär nicht verfügbar (Circuit Breaker offen)",
                Service:   c.breaker.Name(),
                Operation: "call",
                Retryable: true,
                Cause:     err,
            }
        }
        
        if err == gobreaker.ErrTooManyRequests {
            return nil, &errors.ServiceError{
                Code:      errors.ErrorCodeServiceDown,
                Message:   "Service überlastet (Circuit Breaker halb-offen)",
                Service:   c.breaker.Name(),
                Operation: "call",
                Retryable: true,
                Cause:     err,
            }
        }
        
        // Ignorierte Fehler behandeln (entpacken)
        if ignoreErr, ok := err.(gobreaker.ErrIgnore); ok {
            return nil, ignoreErr.Err
        }
        
        return nil, err
    }
    
    return result.(*Response), nil
}

Distributed-Tracing-Integration

Moderne Microservices-Architekturen erfordern Distributed Tracing, um die Fehlerpropagation über Services hinweg zu verstehen. Die Integration von OpenTelemetry mit Ihrem Error Handling bietet umfassende Observability.

package tracing

import (
    "context"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/trace"
    "github.com/yourorg/common/errors"
)

var tracer = otel.Tracer("microservice-errors")

// WithErrorTracing umhüllt eine Funktion mit Distributed Tracing und Error Handling
func WithErrorTracing(ctx context.Context, operationName string, fn func(context.Context) error) error {
    ctx, span := tracer.Start(ctx, operationName)
    defer span.End()
    
    err := fn(ctx)
    if err != nil {
        // Fehlerdetails im Span aufzeichnen
        var serviceErr *errors.ServiceError
        if errors.As(err, &serviceErr) {
            span.SetAttributes(
                attribute.String("error.code", string(serviceErr.Code)),
                attribute.String("error.service", serviceErr.Service),
                attribute.String("error.operation", serviceErr.Operation),
                attribute.Bool("error.retryable", serviceErr.Retryable),
            )
            
            // Span-Status basierend auf Fehlertyp setzen
            if serviceErr.Code == errors.ErrorCodeInternal {
                span.SetStatus(codes.Error, serviceErr.Message)
            } else {
                // Client-Fehler sollten nicht als Span-Fehler markiert werden
                span.SetStatus(codes.Ok, serviceErr.Message)
            }
        } else {
            span.SetStatus(codes.Error, err.Error())
        }
        
        span.RecordError(err)
    }
    
    return err
}

// Erweiterte Service-Methode mit Tracing
func (s *UserService) GetUserWithTracing(ctx context.Context, userID string) (*User, error) {
    return WithErrorTracing(ctx, "user.get", func(ctx context.Context) error {
        user, err := s.GetUser(ctx, userID)
        if err != nil {
            return err
        }
        
        // Benutzer-Attribute zum Span hinzufügen
        span := trace.SpanFromContext(ctx)
        span.SetAttributes(
            attribute.String("user.id", user.ID),
            attribute.String("user.email", user.Email),
        )
        
        return nil
    })
}

Error-Response-Patterns

Schließlich benötigen Sie konsistente Patterns für die Konvertierung interner Fehler zu Client-Antworten, während Sie Sicherheit gewährleisten und nützliche Informationen bereitstellen.

package httphandler

import (
    "encoding/json"
    "net/http"
    "github.com/yourorg/common/errors"
)

type ErrorResponse struct {
    Error struct {
        Code      string `json:"code"`
        Message   string `json:"message"`
        TraceID   string `json:"trace_id,omitempty"`
        Retryable bool   `json:"retryable"`
    } `json:"error"`
}

func HandleError(w http.ResponseWriter, r *http.Request, err error) {
    var serviceErr *errors.ServiceError
    var response ErrorResponse
    var statusCode int
    
    if errors.As(err, &serviceErr) {
        statusCode = serviceErr.HTTPStatusCode()
        response.Error.Code = string(serviceErr.Code)
        response.Error.Message = serviceErr.Message
        response.Error.Retryable = serviceErr.Retryable
    } else {
        // Unbekannter Fehler - interne Details nicht preisgeben
        statusCode = http.StatusInternalServerError
        response.Error.Code = string(errors.ErrorCodeInternal)
        response.Error.Message = "Ein interner Fehler ist aufgetreten"
        response.Error.Retryable = false
    }
    
    // Trace-ID hinzufügen falls verfügbar
    if traceID := r.Context().Value(TraceIDKey); traceID != nil {
        response.Error.TraceID = traceID.(string)
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(statusCode)
    json.NewEncoder(w).Encode(response)
    
    // Fehler für Monitoring protokollieren (mit vollständigem Kontext)
    logError(r.Context(), err, statusCode)
}

func logError(ctx context.Context, err error, statusCode int) {
    // Tracing-Informationen extrahieren
    traceID, _ := ctx.Value(TraceIDKey).(string)
    correlationID, _ := ctx.Value(CorrelationIDKey).(string)
    
    // Mit strukturierten Informationen protokollieren
    log.WithFields(map[string]interface{}{
        "error":          err.Error(),
        "status_code":    statusCode,
        "trace_id":       traceID,
        "correlation_id": correlationID,
    }).Error("HTTP-Request fehlgeschlagen")
}

Best Practices und Sicherheitsüberlegungen

Bei der Implementierung dieser Error-Handling-Patterns befolgen Sie diese Best Practices:

Sicherheit zuerst: Wie im JetBrains Guide für sicheres Error Handling hervorgehoben, stellen Sie sicher, dass Vulnerabilities in Drittanbieter-Komponenten nicht durch Fehlermeldungen introspektiert werden können. Geben Sie niemals interne Systemdetails in client-seitigen Fehlern preis.

Konsistente Error Codes: Verwenden Sie konsistente Error Codes über alle Services hinweg. Dies erleichtert es Clients, Fehler programmatisch zu behandeln und ermöglicht besseres Monitoring und Alerting.

Wiederholbar vs. Nicht-Wiederholbar: Unterscheiden Sie klar zwischen Fehlern, die wiederholt werden können und solchen, die nicht wiederholt werden können. Dies verhindert, dass Clients Ressourcen für aussichtslose Wiederholungsversuche verschwenden.

Context-Erhaltung: Bewahren Sie immer den Fehlerkontext beim Wrapping von Fehlern, aber seien Sie selektiv bei den Informationen, die Service-Grenzen überschreiten.

Fazit

Effektives Error Handling in Go-Microservices erfordert durchdachtes Design und konsistente Patterns. Durch die Implementierung strukturierter Fehlertypen, ordnungsgemäße Context-Propagation und Integration mit Observability-Tools können Sie resiliente Systeme entwickeln, die aussagekräftige Debugging-Informationen bereitstellen und dabei Sicherheit und Performance aufrechterhalten.

Die in diesem Artikel dargestellten Patterns bieten eine Grundlage für das Behandeln von Fehlern in verteilten Systemen. Wie im umfassenden Guide zu Go Error Handling erwähnt, “stellt das errors-Package ein mächtiges Set von Tools für das Error Handling in Go bereit” - und in Kombination mit microservices-spezifischen Patterns werden diese Tools noch mächtiger.

Denken Sie daran, dass Error Handling nicht nur das Abfangen und Zurückgeben von Fehlern bedeutet - es geht darum, Systeme zu entwickeln, die Fehlerzustände elegant behandeln können und Betreibern die Informationen bereitstellen, die sie zur Aufrechterhaltung der Systemgesundheit benötigen.

Lesebarkeit

Schriftgröße