Destino Dev

Blog

Retries exponenciais com jitter em Go: backoff, Context e cancelamento

Por John Pires · 16 de março de 2026

Retries exponenciais com jitter em Go: backoff, Context e cancelamento

Contexto: quando você vai usar isso

Por que retries são necessários

Em sistemas distribuídos, falhas transitórias são normais: um banco de dados pode temporariamente recusar conexões, uma API externa pode responder 503 por alguns segundos, ou uma rede pode ter perda de pacotes. Retentar (retry) operações falhas é uma técnica básica para aumentar a robustez sem intervenção manual. Mas retries simples (tentar novamente imediatamente, ou com intervalos fixos) podem agravar problemas: atacar um serviço já sobrecarregado com muitas requisições simultâneas piora a latência e causa mais erros.

Backoff exponencial e jitter: o que resolvem

Backoff exponencial aumenta o intervalo entre tentativas de forma exponencial (por exemplo, 100ms, 200ms, 400ms...), reduzindo a pressão no serviço. Jitter adiciona variação aleatória a esses intervalos para evitar que múltiplos clientes sincronizem retrys ao mesmo tempo (thundering herd). Juntos, eles são uma prática comprovada para reduzir picos de carga durante falhas.

Em Go, tratamentos de retry devem integrar cancelamento e contexto (context.Context) para que a rotina chamadora controle tempo máximo e término de goroutines. Sem isso, retries podem vazar goroutines que continuam tentando mesmo depois que o programa não precisa mais dos resultados.

Explicação visual com diagramas mentais

Fluxo mental do retry com backoff exponencial

Imagine uma fila de tentativas. A cada falha, você espera um tempo maior antes de tentar de novo. Pense em uma mola que estica cada vez mais: seu próximo estiramento é maior. Em termos práticos:

  1. Tentar operação
  2. Se sucesso → retorna
  3. Se falha e ainda há tentativas permitidas → calcular espera
  4. Esperar (com cancelamento via Context)
  5. Repetir

Como o jitter entra no fluxo

Sem jitter, vários clientes podem seguir a mesma sequência de tempos (100ms, 200ms, ...). Com jitter, cada tempo vira um intervalo aleatório em torno desse valor, por exemplo: 200ms ± 50%, ou um valor aleatório entre 0 e o tempo de backoff. Existem estratégias de jitter: full jitter, equal jitter, decorrelated jitter. A mais simples e recomendada por AWS Labs é o full jitter, onde você usa rand.Int63n(backoff).

Dica: use rand.NewSource com seed baseada em tempo no início do seu programa para evitar sequências repetidas entre execuções.
Diagrama ASCII (exemplo):

 try1 --fail--> wait 100ms +/-jitter --> try2
 try2 --fail--> wait 200ms +/-jitter --> try3
 try3 --fail--> wait 400ms +/-jitter --> try4
 try4 --success--> return

Primeiro exemplo simples

Objetivo do exemplo

Mostrar um retry básico que usa backoff exponencial com full jitter e context.Context para cancelamento. Ideal para entender lógica sem lidar com HTTP ou bibliotecas externas.

Código: função Retry simples em Go

O código abaixo implementa uma função Retry que aceita uma função que pode falhar, número máximo de tentativas, tempo base (initial delay) e context para cancelamento. Ele usa full jitter: espera um valor aleatório entre 0 e backoff cada vez.

package main

import (
  "context"
  "errors"
  "fmt"
  "math/rand"
  "time"
)

// retry simple with exponential backoff + full jitter
func Retry(ctx context.Context, attempts int, baseDelay time.Duration, fn func() error) error {
  if attempts <= 0 {
    return errors.New("attempts must be > 0")
  }
  randSrc := rand.New(rand.NewSource(time.Now().UnixNano()))

  var err error
  var backoff time.Duration

  for i := 0; i < attempts; i++ {
    // Check cancellation before trying
    select {
    case <-ctx.Done():
      return ctx.Err()
    default:
    }

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

    // compute exponential backoff, cap to avoid overflow
    backoff = baseDelay << uint(i) // base * 2^i
    if backoff > 10*time.Second {
      backoff = 10 * time.Second
    }

    // full jitter: random between 0 and backoff
    sleep := time.Duration(randSrc.Int63n(int64(backoff)))

    // wait respecting context cancellation
    select {
    case <-time.After(sleep):
      // continue to next attempt
    case <-ctx.Done():
      return ctx.Err()
    }
  }
  return fmt.Errorf("all attempts failed, last error: %w", err)
}

func main() {}

O que faz: implementa o loop de tentativas com cálculo de backoff exponencial, aplica jitter e respeita o ctx.Done() tanto antes da tentativa quanto durante o sono (sleep). O código principal (main) está vazio porque isso é um módulo reutilizável.

Dica: esse padrão de chamar select com ctx.Done antes de executar a operação evita iniciar trabalho desnecessário quando já houve cancelamento.

O que acontece quando você executa

Ao usar Retry, você obterá retorno imediato se a operação for bem-sucedida; caso contrário, ele tentará até o número máximo de tentativas ou até o Context for cancelado. Se o Context expirar, a função retorna imediatamente com o erro de contexto (por exemplo, context.DeadlineExceeded).

Segundo exemplo mais realista

Objetivo do exemplo realista

Aqui vamos integrar o retry com uma chamada HTTP simples, mostrando como fazer request com timeout por tentativa, usar contexto de timeout global e combinar com backoff exponencial com jitter. Este cenário é típico em CLI tools e clients REST em Go.

Código: retry para requisição HTTP com timeout por tentativa

O exemplo faz requisições GET para um endpoint fictício. Cada tentativa tem seu próprio timeout curto (per-attempt timeout) e há um timeout geral via context.WithTimeout. O retry respeita cancelamento global.

package main

import (
  "context"
  "fmt"
  "io/ioutil"
  "math/rand"
  "net/http"
  "time"
)

// httpGetWithRetry tenta GET com retries, backoff exponencial e full jitter
func httpGetWithRetry(ctx context.Context, url string, attempts int, baseDelay time.Duration) ([]byte, error) {
  client := &http.Client{}
  randSrc := rand.New(rand.NewSource(time.Now().UnixNano()))

  var lastErr error
  for i := 0; i < attempts; i++ {
    // check global cancel
    select {
    case <-ctx.Done():
      return nil, ctx.Err()
    default:
    }

    // per-attempt ctx: evita request travado por muito tempo
    attemptCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
    req, _ := http.NewRequestWithContext(attemptCtx, http.MethodGet, url, nil)

    resp, err := client.Do(req)
    cancel() // cancel per-attempt context resources

    if err == nil && resp != nil {
      body, _ := ioutil.ReadAll(resp.Body)
      resp.Body.Close()
      if resp.StatusCode >= 200 && resp.StatusCode < 300 {
        return body, nil
      }
      lastErr = fmt.Errorf("status %d", resp.StatusCode)
    } else {
      lastErr = err
    }

    // calculate backoff with cap
    backoff := baseDelay << uint(i)
    if backoff > 10*time.Second {
      backoff = 10 * time.Second
    }
    sleep := time.Duration(randSrc.Int63n(int64(backoff)))

    select {
    case <-time.After(sleep):
      // next attempt
    case <-ctx.Done():
      return nil, ctx.Err()
    }
  }
  return nil, fmt.Errorf("all attempts failed, last: %w", lastErr)
}

func main() {
  ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
  defer cancel()

  url := "https://httpbin.org/status/503"
  body, err := httpGetWithRetry(ctx, url, 5, 200*time.Millisecond)
  if err != nil {
    fmt.Println("request failed:", err)
    return
  }
  fmt.Println("success, body len:", len(body))
}

O que faz: cada tentativa tem timeout de 2s; o contexto global limita todo o processo a 15s. O backoff é calculado e aplicado com jitter como no exemplo simples. Se a API responder 503, a função fará retries até o limite ou até o contexto expirar.

Observações práticas sobre esse código

1) O uso de NewRequestWithContext associa o cancelamento ao request HTTP, fazendo com que o cliente pare a leitura se o contexto for cancelado. 2) Cancelamos explicitamente o context do per-attempt usando cancel() logo após o Do para liberar timers. 3) Use um client reutilizável para performance; aqui demonstramos o padrão simples.

Erros comuns de iniciante e como evitar

1) Vazar goroutines por não respeitar Context

Erro: iniciar retries em goroutines sem ligar o ciclo de vida ao contexto. Isso leva a goroutines que continuam tentando mesmo quando a aplicação ou a requisição pai já morreu, causando vazamento de recursos.

  • Como evitar: sempre verificar ctx.Done() antes de iniciar trabalho e usar request com contexto (NewRequestWithContext).
  • Use select com <-ctx.Done() em sleeps para interromper espera.

2) Usar jitter incorreto

Erro: aplicar jitter de forma que aumente a latência esperada (por exemplo, adicionar jitter positivo a um backoff já grande sem cap). Outra armadilha é usar rand global sem seed, resultando em padrões previsíveis.

  • Como evitar: prefira full jitter (rand entre 0 e backoff) ou equal jitter conforme sua necessidade; faça cap no backoff para evitar esperar dias.
  • Seed a RNG no início do processo com time.Now().UnixNano().

3) Repetir operações idempotentes vs não-idempotentes

Erro: aplicar retry a operações não idempotentes sem cuidado (por exemplo, criar um recurso várias vezes pode duplicar). Retries são seguros para operações idempotentes: GETs, reads, ou POSTs com idempotency key.

  • Como evitar: garanta idempotency (headers, keys) ou limite retries apenas a operações que toleram repetição.

4) Não diferenciar erros

Erro: retentar indiscriminadamente para todo tipo de erro (400 Bad Request, 401 Unauthorized). Algumas classes de erro não são recuperáveis por retry e só consomem tempo e recursos.

  • Como evitar: classifique erros. Retente para 5xx (servidor), timeouts e erros de rede; não retente para 4xx sem lógica adicional.

5) Falta de limites e caps

Erro: backoff sem cap resultando em espera enorme, ou número de tentativas sem limite que gera loops longos. Sempre especifique limites (max attempts, max backoff).

Dica: combine um número máximo de tentativas com um timeout global via Context para garantir limpeza previsível.

Exemplos adicionais e variações

Exemplo: decorrelated jitter (variação)

Decorrelated jitter evita padrões previsíveis usando uma fórmula que mistura o previous sleep com random. É útil quando você quer balancear entre esperar muito e sincronização. Exemplo simplificado:

// pseudo: sleep = min(maxDelay, randBetween(base, sleep*3))
prev := base
for i := 0; i < attempts; i++ {
  sleep = randBetween(base, prev*3)
  prev = sleep
  // sleep respecting ctx
}

O que acontece: evita enormes saltos e reduz chance de sincronização. Implementar corretamente envolve cuidado com overflow e caps.

Exemplo: backoff com tentativa baseada em cabeçalhos Retry-After

Algumas APIs retornam cabeçalho Retry-After. Quando presente, prefira usar esse valor em vez do backoff local. Mas combine com um máximo para proteger seu cliente.

// leia header Retry-After e converta para duration; respeite um maxBackoff
// se header inválido, caia no backoff exponencial com jitter

Conclusão prática

Retries exponenciais com jitter são uma ferramenta essencial para tornar clientes mais resilientes em face de falhas transitórias. Em Go, a integração com context.Context e cancelamento é obrigatória para evitar vazamentos e permitir controle centralizado do ciclo de vida.

Checklist prático antes de implementar retries:

  1. Identifique se a operação é idempotente ou se existe mecanismo de idempotency.
  2. Escolha estratégia de jitter (full jitter é um bom começo).
  3. Use backoff exponencial com cap e número máximo de tentativas.
  4. Respeite context.Context: verifique ctx.Done antes de iniciar tentativas e durante sleeps.
  5. Classifique erros: retente apenas para erros recuperáveis (timeouts, 5xx, network errors).

Implementar essas práticas em Go é direto: use select com ctx.Done, NewRequestWithContext para HTTP, e uma RNG bem inicializada para jitter. Comece com o exemplo simples e migre para o realista (HTTP) quando compreender os conceitos. Esses padrões reduzem a chance de criar cargas desnecessárias sobre serviços degradados e melhoram a experiência do usuário sem introducir complexidade excessiva.

Se você está começando, experimente rodar os exemplos locais e alterar parâmetros (baseDelay, attempts, timeouts) para ver o comportamento: observe quantas tentativas ocorrem, quando o contexto expira e como jitter distribui tempos de espera.

Assuntos: Go, concurrency, backoff, context, API REST, retry

Leia também