O padrão de nova tentativa: criando microsserviços resilientes

O padrão de nova tentativa: criando microsserviços resilientes

Numa arquitetura de microsserviços, os serviços comunicam-se através de uma rede em vez de chamadas na memória. Embora essa dissociação permita o dimensionamento horizontal massivo e implantações independentes, ela também introduz uma grande vulnerabilidade: a rede não é confiável.

A qualquer momento, um serviço downstream pode enfrentar uma breve falha na rede, um pico temporário de CPU, uma rápida contenção de bloqueio de banco de dados ou uma reinicialização de atualização contínua. Essas falhas temporárias são conhecidas como falhas transitórias.

Se o seu serviço gerar um erro imediatamente e falhar na solicitação no momento em que uma chamada downstream falhar, você criará uma experiência de usuário frágil. Em vez disso, muitos desses erros transitórios podem ser resolvidos automaticamente aguardando um momento e tentando novamente. É aqui que entra o Padrão de Nova Tentativa.

Neste guia, exploraremos o padrão Retry, como ele funciona nos bastidores, os perigos das implementações ingênuas e como implementá-lo corretamente em Java (Resilience4j) e Go.


A analogia do mundo real: rediscando uma linha ocupada

Imagine que você está tentando ligar para um amigo. Você disca o número deles, mas recebe um sinal de ocupado porque eles estão em outra chamada.

Você desiste imediatamente, exclui o contato e presume que nunca mais poderá falar com ele? Claro que não. Você desliga, espera um minuto e disca o número novamente. Se eles ainda estiverem ocupados, espere cinco minutos antes de tentar novamente.

Eventualmente, a chamada termina e sua nova tentativa é bem-sucedida.

Diagrama de arquitetura de padrão de repetição mostrando tentativa de solicitação, atraso de espera e segunda tentativa de nova tentativa bem-sucedida

Em microsserviços:

  • A chamada é uma solicitação de API para um serviço downstream.
  • O sinal de ocupado é um erro transitório de rede ou uma resposta 503 Service Unavailable.
  • A Rediscagem é uma nova tentativa.
  • O tempo de espera é a duração da espera.

O perigo: tentativas ingênuas e “tempestades de novas tentativas”

Implementar um mecanismo de nova tentativa parece trivial à primeira vista: basta envolver sua chamada HTTP em um loop for e continuar tentando até obter sucesso. No entanto, uma implementação ingênua de nova tentativa pode facilmente transformar um pequeno problema em uma interrupção catastrófica de todo o sistema.

Imagine um serviço downstream que está enfrentando um aumento repentino de tráfego. Seu banco de dados está sendo executado com 99% de utilização da CPU e as solicitações estão começando a atingir o tempo limite.

Se 100 serviços de cliente detectarem um tempo limite e tentarem imediatamente três vezes sem esperar, eles triplicarão repentinamente o volume de tráfego que atinge o já estrangulado serviço downstream. Essa amplificação repentina do tráfego é conhecida como Tempestade de Novas Tentativas (ou problema do Rebanho Trovejante).

Em vez de ajudar a recuperação do serviço downstream, suas novas tentativas continuarão empurrando-o para baixo, impedindo que ele se recupere.


A solução: recuo e instabilidade

Para evitar tempestades de novas tentativas, um sistema resiliente deve utilizar duas estratégias essenciais: Backoff e Jitter.

1. Estratégias de retirada

A retirada determina quanto tempo um cliente deve esperar antes de fazer uma nova tentativa subsequente.

  • Backoff fixo: o cliente espera um período de tempo constante (por exemplo, exatamente 200 ms) entre as tentativas. Embora simples, ainda corre o risco de picos de tráfego sincronizados.
  • Retirada Exponencial: O tempo de espera aumenta exponencialmente com cada tentativa falhada (por exemplo, 100ms, 200ms, 400ms, 800ms). Isso dá ao serviço downstream progressivamente mais tempo para se recuperar à medida que a falha persiste.

2. Jitter (aleatoriedade)

Mesmo com espera exponencial, se um sinal de rede fizer com que 1.000 solicitações falhem exatamente no mesmo instante, todos os 1.000 clientes calcularão exatamente o mesmo atraso de espera. Conseqüentemente, todos eles tentarão novamente simultaneamente em ondas, atingindo o serviço downstream com picos sincronizados.

Jitter resolve isso adicionando variação aleatória ao atraso de espera.

Without Jitter (Synchronized Waves):
Time: 0ms   -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]

With Jitter (Distributed Traffic):
Time: 0ms   -> [1000 requests fail]
Time: 92ms  -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)

Ao distribuir as novas tentativas em um intervalo aleatório, a carga no serviço downstream é suavizada, permitindo que ele se recupere normalmente.


A regra de ouro das novas tentativas: idempotência

Antes de aplicar novas tentativas a qualquer endpoint de API, você deve fazer uma pergunta crítica: Esta operação é idempotente?

Uma operação idempotente é aquela em que fazer várias solicitações idênticas tem exatamente o mesmo efeito que fazer uma única solicitação.

  • Idempotente: leitura de um perfil de usuário (GET /users/123), atualização de um campo de e-mail inteiro (PUT /users/123/email) ou exclusão de um item (DELETE /items/456).
  • Não idempotente: criação de um novo pedido (POST /orders) ou processamento de uma cobrança de cartão de crédito (POST /payments).

Suponha que você ligue para POST /payments para cobrar US$ 50 de um cliente. O gateway de pagamento downstream recebe a solicitação, cobra o cartão de crédito com sucesso, mas ocorre uma falha na rede antes que ele possa enviar a resposta 200 OK de volta para você.

Seu serviço registra um tempo limite, assume que a chamada falhou e tenta novamente automaticamente. Se o gateway de pagamento não for projetado para lidar com solicitações duplicadas, o cliente será cobrado duas vezes.

[!AVISO] Nunca tente novamente operações não idempotentes, a menos que o serviço downstream suporte Chaves de idempotência (identificadores de solicitação exclusivos usados para detectar e descartar transações duplicadas).


Exemplos de implementação

Vejamos como implementar o padrão Retry em Java e Go.

1. Java (Resilience4j e Spring Boot)

O Resilience4j fornece um módulo de nova tentativa robusto e altamente configurável. Veja como configurá-lo para um serviço de faturamento externo.

Configuração (application.yml)

resilience4j.retry:
  instances:
    billingService:
      maxAttempts: 3
      waitDuration: 100ms
      enableExponentialBackoff: true
      exponentialBackoffMultiplier: 2.0
      enableRandomizedWait: true
      randomizedWaitFactor: 0.5
      retryExceptions:
        - org.springframework.web.client.ResourceAccessException
        - io.netty.channel.ConnectTimeoutException
      ignoreExceptions:
        - org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried

Implementação de código

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class PaymentProcessor {

    private final RestTemplate restTemplate;

    public PaymentProcessor(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply the configured retry policy with a fallback method
    @Retry(name = "billingService", fallbackMethod = "billingFallback")
    public String chargeUser(BillingRequest request) {
        return restTemplate.postForObject("http://billing-service/charge", request, String.class);
    }

    // Executed when all retry attempts fail
    public String billingFallback(BillingRequest request, Throwable throwable) {
        return "Billing service is currently unavailable. Your transaction will be queued.";
    }
}

2. Vá (Golang)

No Go, podemos escrever um executor de repetição elegante com espera exponencial e jitter aleatório usando temporizadores de biblioteca padrão.

package main

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

// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
	MaxAttempts int
	MinBackoff  time.Duration
	MaxBackoff  time.Duration
}

// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
	var err error
	
	for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
		err = operation()
		if err == nil {
			return nil // Success!
		}

		if attempt == config.MaxAttempts {
			break
		}

		// Calculate exponential backoff
		// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
		backoff := config.MinBackoff * (1 << (attempt - 1))
		if backoff > config.MaxBackoff || backoff <= 0 {
			backoff = config.MaxBackoff
		}

		// Apply Full Jitter: wait randomly between 0 and backoff
		jitter := time.Duration(rand.Int63n(int64(backoff)))

		fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)

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

	return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}

func main() {
	config := RetryConfig{
		MaxAttempts: 4,
		MinBackoff:  100 * time.Millisecond,
		MaxBackoff:  2000 * time.Millisecond,
	}

	// Mock function that fails 3 times and succeeds on the 4th
	attempts := 0
	mockAPI := func() error {
		attempts++
		if attempts < 4 {
			return errors.New("network timeout (503)")
		}
		return nil
	}

	ctx := context.Background()
	err := Execute(ctx, config, mockAPI)
	if err != nil {
		fmt.Printf("Final Outcome: %v\n", err)
	} else {
		fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
	}
}

Nova tentativa vs. disjuntor: quando usar qual?

Os desenvolvedores costumam confundir o Padrão de Nova Tentativa com o Padrão de Disjuntor. Embora ambos visem melhorar a resiliência do sistema, eles lidam com tipos de falhas fundamentalmente diferentes:

Métrica Repetir padrão Padrão de disjuntor
Meta principal Recupera-se automaticamente de falhas transitórias (de curta duração). Evita que falhas persistentes (de longa duração) derrubem o chamador.
Estratégia Tente novamente imediatamente ou após uma breve espera. Fail-fast imediatamente sem invocar o serviço de destino.
Impacto a jusante Aumenta temporariamente a carga no serviço downstream. Protege o serviço downstream do tráfego, permitindo sua recuperação.
Gatilhos típicos Breves desconexões de rede, tempos limite de banco de dados, quedas de soquete. O serviço downstream está completamente inativo, retornando 500 segundos ou tempos limite contínuos.

O casal poderoso: combinando os dois

Em sistemas de produção, esses dois padrões são projetados para serem usados juntos.

Quando você faz uma solicitação downstream, ela deve passar primeiro pelo Circuit Breaker e depois pelo wrapper Retry.

Se ocorrer uma falha transitória, o mecanismo Retry a interceptará e tentará novamente. No entanto, se o serviço downstream estiver inoperante, as falhas se acumularão. O disjuntor detecta as falhas consecutivas, “desarma” e bloqueia todas as chamadas futuras.

Agora, quando você tenta chamar o serviço, o disjuntor falha rapidamente e imediatamente, ignorando totalmente o loop de nova tentativa e economizando recursos de computação.


Melhores práticas para o padrão de nova tentativa

  1. Apenas Repetir Erros Transitórios: Inspecione os códigos de status HTTP. Tente novamente 503 Service Unavailable, 429 Too Many Requests (respeitando os cabeçalhos Retry-After, se presentes) e os tempos limite da rede. NÃO tente novamente 400 Bad Request, 401 Unauthorized ou 404 Not Found – eles nunca terão sucesso na nova tentativa.
  2. Aplicar Jitter: Nunca use um temporizador de repetição fixo sem introduzir jitter aleatório.
  3. Limitar máximo de tentativas: pare de tentar novamente após um limite razoável (normalmente de 3 a 5 tentativas). Tentativas ilimitadas consomem recursos e reduzem a latência.
  4. Tenha cuidado com novas tentativas em cascata: se o serviço A chamar o serviço B (que tenta novamente 3 vezes) e o serviço B chamar o serviço C (que tenta novamente 3 vezes), uma falha em C pode resultar em $3 \times 3 = 9$ chamadas em cascata. Aplique novas tentativas apenas nos limites onde elas fazem mais sentido.
  5. Sempre defina tempos limite rígidos: certifique-se de que os tempos limites da rede sejam menores que os períodos de espera, para que os threads não sejam retidos indefinidamente.

Conclusão

O Retry Pattern é uma poderosa primeira linha de defesa contra a falta de confiabilidade da rede em arquiteturas de microsserviços. Quando combinado com Backoff exponencial, Jitter e um Disjuntor, você pode proteger seus sistemas contra interrupções em cascata e criar uma experiência perfeita e de autocorreção para seus usuários.