Retry policies e backoff exponencial

1. Fundamentos de Retry Policies

Em sistemas distribuídos, falhas são inevitáveis. Redes instáveis, picos de tráfego e serviços temporariamente indisponíveis fazem parte do dia a dia de qualquer aplicação que se comunica com outros serviços. Retry policies são estratégias que definem como e quando devemos repetir uma operação que falhou.

Os erros tratáveis por retry são chamados de erros transitórios — aqueles que têm alta probabilidade de serem resolvidos após um breve período. Exemplos incluem timeouts de rede, conexões recusadas, e respostas HTTP 503 (Service Unavailable). Já os erros permanentes (como 400 Bad Request ou 404 Not Found) não devem ser repetidos, pois a operação sempre falhará.

A estrutura mais básica de retry em Go utiliza um loop for combinado com time.Sleep:

func operacaoComRetry() error {
    maxTentativas := 3
    var err error

    for i := 0; i < maxTentativas; i++ {
        err = chamadaExterna()
        if err == nil {
            return nil
        }
        time.Sleep(1 * time.Second)
    }
    return err
}

Embora funcional, essa abordagem ingênua tem limitações sérias: espera fixa entre tentativas e nenhum controle sobre o tempo total de espera.

2. Backoff Exponencial: Conceito e Implementação

Backoff exponencial resolve o problema da espera fixa aumentando progressivamente o intervalo entre tentativas. A fórmula matemática é:

delay = base * 2^n

Onde base é o intervalo inicial (ex: 100ms) e n é o número da tentativa (começando em 0).

Implementação manual em Go:

func backoffExponencial(base time.Duration, tentativa int) time.Duration {
    segundos := base.Seconds() * math.Pow(2, float64(tentativa))
    return time.Duration(segundos) * time.Second
}

func operacaoComBackoff() error {
    maxTentativas := 5
    base := 100 * time.Millisecond

    for i := 0; i < maxTentativas; i++ {
        err := chamadaExterna()
        if err == nil {
            return nil
        }

        espera := backoffExponencial(base, i)
        time.Sleep(espera)
    }
    return fmt.Errorf("falha apos %d tentativas", maxTentativas)
}

O problema do backoff puro é que, com muitas tentativas, o tempo de espera cresce rapidamente. Para 5 tentativas com base de 100ms, a última espera seria de 3.2 segundos. Além disso, sem aleatoriedade, todos os clientes que falham simultaneamente tentarão novamente no mesmo momento, causando o efeito manada (thundering herd).

3. Jitter: Adicionando Aleatoriedade ao Backoff

Jitter introduz variação aleatória nos intervalos de espera para distribuir as tentativas de retry no tempo. Existem três tipos principais:

Full jitter: aleatoriza entre 0 e o valor máximo calculado

func fullJitter(base time.Duration, tentativa int) time.Duration {
    maxEspera := float64(base) * math.Pow(2, float64(tentativa))
    return time.Duration(rand.Float64() * maxEspera)
}

Equal jitter: divide o intervalo em duas partes, uma fixa e uma aleatória

func equalJitter(base time.Duration, tentativa int) time.Duration {
    espera := float64(base) * math.Pow(2, float64(tentativa))
    metade := espera / 2
    return time.Duration(metade + rand.Float64()*metade)
}

Decorrelated jitter: usa o valor anterior para calcular o próximo

func decorrelatedJitter(base, max time.Duration, anterior time.Duration) time.Duration {
    min := float64(base)
    maxJitter := float64(anterior) * 3
    if maxJitter > float64(max) {
        maxJitter = float64(max)
    }
    return time.Duration(min + rand.Float64()*(maxJitter-min))
}

4. Bibliotecas Prontas no Ecossistema Go

cenkalti/backoff

A biblioteca mais popular, oferece implementações robustas de backoff:

import "github.com/cenkalti/backoff/v4"

func main() {
    b := backoff.NewExponentialBackOff()
    b.InitialInterval = 500 * time.Millisecond
    b.MaxInterval = 30 * time.Second
    b.MaxElapsedTime = 2 * time.Minute

    err := backoff.Retry(func() error {
        return chamadaExterna()
    }, b)
}

RetryNotify adiciona callbacks para falhas parciais:

err := backoff.RetryNotify(
    operacao,
    b,
    func(err error, d time.Duration) {
        log.Printf("Falha na tentativa, esperando %v: %v", d, err)
    },
)

avast/retry-go

Oferece API declarativa com opções:

import "github.com/avast/retry-go/v4"

func main() {
    err := retry.Do(
        func() error { return chamadaExterna() },
        retry.Attempts(5),
        retry.Delay(100 * time.Millisecond),
        retry.MaxDelay(10 * time.Second),
        retry.DelayType(retry.BackOffDelay),
    )
}

hashicorp/go-retryablehttp

Especializado para clientes HTTP:

import "github.com/hashicorp/go-retryablehttp"

func main() {
    client := retryablehttp.NewClient()
    client.RetryMax = 3
    client.RetryWaitMin = 1 * time.Second
    client.RetryWaitMax = 10 * time.Second

    resp, err := client.Get("https://api.exemplo.com/recurso")
}

5. Integração com Context e Cancelamento

Context.Context permite propagar deadlines e cancelamentos através do pipeline:

func operacaoComContext(ctx context.Context) error {
    b := backoff.NewExponentialBackOff()
    b.MaxElapsedTime = 30 * time.Second

    return backoff.Retry(func() error {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        return chamadaExterna()
    }, backoff.WithContext(b, ctx))
}

Verificação manual dentro do loop:

func operacaoManual(ctx context.Context) error {
    b := backoff.NewExponentialBackOff()
    for {
        err := chamadaExterna()
        if err == nil {
            return nil
        }

        espera := b.NextBackOff()
        if espera == backoff.Stop {
            return err
        }

        select {
        case <-time.After(espera):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

6. Estratégias Avançadas de Retry

Circuit Breaker

Interrompe tentativas após um limite de falhas consecutivas:

type CircuitBreaker struct {
    maxFalhas    int
    falhasAtuais int
    mu           sync.Mutex
}

func (cb *CircuitBreaker) Execute(fn func() error) error {
    cb.mu.Lock()
    if cb.falhasAtuais >= cb.maxFalhas {
        cb.mu.Unlock()
        return fmt.Errorf("circuit breaker aberto")
    }
    cb.mu.Unlock()

    err := fn()
    if err != nil {
        cb.mu.Lock()
        cb.falhasAtuais++
        cb.mu.Unlock()
    } else {
        cb.mu.Lock()
        cb.falhasAtuais = 0
        cb.mu.Unlock()
    }
    return err
}

Retry baseado em status HTTP

Nem todo erro merece retry:

func deveRetentar(statusCode int) bool {
    switch statusCode {
    case http.StatusTooManyRequests, http.StatusServiceUnavailable,
         http.StatusGatewayTimeout, http.StatusBadGateway:
        return true
    default:
        return false
    }
}

7. Monitoramento e Logging de Tentativas

Logging estruturado com slog:

import "log/slog"

func operacaoMonitorada() error {
    logger := slog.Default()
    b := backoff.NewExponentialBackOff()

    return backoff.RetryNotify(
        func() error { return chamadaExterna() },
        b,
        func(err error, d time.Duration) {
            logger.Warn("retry",
                "erro", err,
                "proxima_espera", d,
            )
        },
    )
}

Métricas com Prometheus:

var (
    retryCount = promauto.NewCounter(prometheus.CounterOpts{
        Name: "operacoes_retry_total",
        Help: "Total de operações com retry",
    })
    retryLatency = promauto.NewHistogram(prometheus.HistogramOpts{
        Name: "operacoes_retry_latency_seconds",
        Help: "Latência das operações com retry",
        Buckets: prometheus.DefBuckets,
    })
)

8. Boas Práticas e Armadilhas Comuns

Limite máximo de tentativas e tempo total: Sempre defina limites para evitar loops infinitos:

b := backoff.NewExponentialBackOff()
b.MaxElapsedTime = 1 * time.Minute // tempo total máximo
b.MaxInterval = 10 * time.Second    // intervalo máximo entre tentativas

Evite goroutines presas: Use context para cancelamento e sempre verifique ctx.Err():

go func() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    err := operacaoComContext(ctx)
}()

Testes unitários: Mock de temporizadores com pacotes como clock:

import "github.com/benbjohnson/clock"

func TestBackoff(t *testing.T) {
    mockClock := clock.NewMock()
    b := backoff.NewExponentialBackOff()
    b.Clock = mockClock
    // avança o tempo manualmente nos testes
}

Armadilha comum: esquecer de resetar o backoff entre operações bem-sucedidas:

func handler() {
    b := backoff.NewExponentialBackOff()
    for {
        err := operacao()
        if err != nil {
            time.Sleep(b.NextBackOff())
        } else {
            b.Reset() // essencial!
        }
    }
}

Referências