🔄 Obsługa błędów i mechanizmy ponawiania w Go

Kompleksowy przewodnik po budowaniu odpornych aplikacji

📋 Spis treści

🎯 Wprowadzenie do scenariuszy awarii

W systemach rozproszonych i programowaniu sieciowym awarie są nieuniknione. Do typowych scenariuszy należą:

Implementacja odpowiednich mechanizmów ponawiania sprawia, że aplikacje są bardziej odporne i niezawodne.

🔁 Podstawowy wzorzec ponawiania

Najprostszy mechanizm ponawiania polega na wielokrotnym wykonywaniu operacji z ustalonym opóźnieniem pomiędzy próbami.

Przykład: prosta funkcja ponawiania

package main

import (
    "errors"
    "fmt"
    "time"
)

// Retry wykonuje przekazaną funkcję maksymalnie maxAttempts razy
func Retry(maxAttempts int, delay time.Duration, fn func() error) error {
    var err error

    for attempt := 1; attempt <= maxAttempts; attempt++ {
        err = fn()
        if err == nil {
            return nil // Sukces
        }

        if attempt < maxAttempts {
            fmt.Printf("Próba %d nie powiodła się: %v. Ponawianie za %v...\n",
                attempt, err, delay)
            time.Sleep(delay)
        }
    }

    return fmt.Errorf("po %d próbach, ostatni błąd: %w", maxAttempts, err)
}

// Przykładowe użycie
func main() {
    counter := 0

    // Symulowana operacja, która dwa razy zawodzi, a potem się udaje
    operation := func() error {
        counter++
        if counter < 3 {
            return errors.New("tymczasowy błąd")
        }
        return nil
    }

    err := Retry(5, 1*time.Second, operation)
    if err != nil {
        fmt.Printf("Operacja nie powiodła się: %v\n", err)
    } else {
        fmt.Println("Operacja zakończona sukcesem!")
    }
}
💡 Uwaga: Stałe opóźnienia mogą przeciążać systemy przy dużym ruchu.

⏱️ Wykładnicze opóźnienie

Wykładnicze opóźnienie zwiększa czas oczekiwania pomiędzy kolejnymi próbami, zmniejszając obciążenie niedostępnych usług.

// RetryWithBackoff implementuje ponawianie z wykładniczym opóźnieniem
func RetryWithBackoff(maxAttempts int, initialDelay time.Duration,
    maxDelay time.Duration, fn func() error) error {

    var err error
    delay := initialDelay

    for attempt := 1; attempt <= maxAttempts; attempt++ {
        err = fn()
        if err == nil {
            return nil
        }

        if attempt < maxAttempts {
            // Oblicz wykładnicze opóźnienie
            delay = time.Duration(float64(initialDelay) * math.Pow(2, float64(attempt-1)))

            // Ogranicz maksymalne opóźnienie
            if delay > maxDelay {
                delay = maxDelay
            }

            fmt.Printf("Próba %d nie powiodła się: %v. Ponawianie za %v...\n",
                attempt, err, delay)
            time.Sleep(delay)
        }
    }
    return fmt.Errorf("niepowodzenie po %d próbach: %w", maxAttempts, err)
}

⏰ Ponawianie z kontekstem i limitem czasu

Pakiet context umożliwia ustawienie limitów czasu i anulowanie ponawiania.

// RetryWithContext respektuje anulowanie i timeout kontekstu
func RetryWithContext(ctx context.Context, maxAttempts int,
    delay time.Duration, fn func(context.Context) error) error {

    var err error

    for attempt := 1; attempt <= maxAttempts; attempt++ {
        // Sprawdź, czy kontekst nie został anulowany
        select {
        case <-ctx.Done():
            return fmt.Errorf("operacja anulowana: %w", ctx.Err())
        default:
        }

        err = fn(ctx)
        if err == nil {
            return nil
        }

        if attempt < maxAttempts {
            fmt.Printf("Próba %d nie powiodła się: %v. Ponawianie...\n", attempt, err)

            select {
            case <-time.After(delay):
            case <-ctx.Done():
                return fmt.Errorf("ponawianie anulowane: %w", ctx.Err())
            }
        }
    }
    return fmt.Errorf("osiągnięto maksymalną liczbę prób: %w", err)
}
⚠️ Ostrzeżenie: Zawsze respektuj anulowanie kontekstu.

🔌 Wzorzec circuit breaker

Wyłącznik obwodu zapobiega wielokrotnemu wywoływaniu niedostępnej usługi.

// Call wykonuje funkcję tylko jeśli wyłącznik nie jest otwarty
func (cb *CircuitBreaker) Call(fn func() error) error {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    if cb.state == StateOpen {
        if time.Since(cb.lastFailTime) > cb.resetTimeout {
            cb.state = StateHalfOpen
            cb.failures = 0
            fmt.Println("Circuit breaker: Otwarty -> Półotwarty")
        } else {
            return errors.New("circuit breaker jest otwarty")
        }
    }

    err := fn()
    if err != nil {
        cb.failures++
        cb.lastFailTime = time.Now()
        if cb.failures >= cb.maxFailures {
            cb.state = StateOpen
        }
        return err
    }

    if cb.state == StateHalfOpen {
        cb.state = StateClosed
    }
    cb.failures = 0
    return nil
}

🎲 Jitter w systemach rozproszonych

Jitter wprowadza losowość opóźnień, zapobiegając efektowi thundering herd.

// RetryWithJitter dodaje losowy jitter do wykładniczego opóźnienia
func RetryWithJitter(maxAttempts int, baseDelay time.Duration,
    maxDelay time.Duration, fn func() error) error {

    var err error

    for attempt := 1; attempt <= maxAttempts; attempt++ {
        err = fn()
        if err == nil {
            return nil
        }

        if attempt < maxAttempts {
            expDelay := float64(baseDelay) * math.Pow(2, float64(attempt-1))
            jitter := rand.Float64() * expDelay
            delay := time.Duration(jitter)

            if delay > maxDelay {
                delay = maxDelay
            }

            fmt.Printf("Próba %d nie powiodła się. Ponawianie za %v...\n", attempt, delay)
            time.Sleep(delay)
        }
    }
    return fmt.Errorf("niepowodzenie po %d próbach: %w", maxAttempts, err)
}
✅ Wskazówka: Jitter jest kluczowy w systemach rozproszonych.

✨ Najlepsze praktyki