🧠FFVAcademy
🔁

Idempotência e Retries: o antídoto pra rede que quebra

15 min de leitura·+75 XP
Pré-requisitos (0/1)0%

Recomendamos completar os pré-requisitos antes de seguir, mas nada te impede de continuar.

Todas as redes mentem. Pacotes se perdem. Timeouts disparam antes da resposta chegar. Servidores crasham entre processar a requisição e mandar o ACK. O cliente não tem como saber se a operação aconteceu — então ele retria. Se a operação não é idempotente, você acaba de debitar o cartão duas vezes. Se ela é, tudo bem. Este módulo é sobre as ferramentas que separam aplicações amadoras de sistemas que sobrevivem a AWS caindo no meio da noite.

Vamos cobrir: o que é idempotência formalmente, como implementar idempotency keys do jeito que a Stripe faz, backoff exponencial com jitter (e por que jitter não é opcional), circuit breakers pra não afogar serviços recuperando, e as garantias reais de entrega em brokers como Kafka, SQS e RabbitMQ.

O que é idempotência (e quais operações já são)

Formalmente: uma função f é idempotente se f(f(x)) = f(x). Aplicada em sistemas distribuídos: chamar a operação 1 vez ou 50 vezes deixa o sistema no mesmo estado final.

OperaçãoIdempotente?Exemplo
GETSim (sem side effects)GET /users/42
PUTSim (substitui por completo)PUT /users/42 {"name":"Ana"}
DELETESim (se já apagado, continua apagado)DELETE /users/42
POST (sem idempotency key)NãoPOST /payments → cria novo pagamento cada chamada
POST (com idempotency key)Sim (com key + dedup server-side)POST /payments com Idempotency-Key: uuid
Incremento: counter += 1NãoRepetir → incrementa de novo
Set absoluto: counter = 42SimQualquer número de repetições → 42
Compare-and-swap (CAS)Sim (primeira aplica, demais falham sem erro)UPDATE ... WHERE version=X
⚠️
Pegadinha clássica: "enviar email de boas-vindas" não é idempotente. Se você retria após crash, manda o email duas vezes. Solução: wrap em idempotency key (ex: send_email(user_id, template='welcome') grava em tabela sent_emails (user_id, template) UNIQUE antes de enviar).

Idempotency Keys: a pedra fundamental

O padrão canônico, popularizado pela Stripe (2015): o cliente gera um UUID por intenção de operação e passa no header Idempotency-Key: <uuid>. O servidor:

  1. Busca a key em storage (Redis, Postgres)
  2. Se existe: retorna a resposta armazenada — mesma exata que foi enviada antes
  3. Se não existe: executa a operação, grava a resposta com a key, retorna
CLIENTE                    SERVER                 DB
   │                          │                     │
   ├─ POST /payments ────────►│                     │
   │  Idempotency-Key: abc123 │                     │
   │                          ├─ SELECT ... WHERE key='abc123'  ──►
   │                          │◄─ not found        ◄──
   │                          │                     │
   │                          ├─ BEGIN TX           │
   │                          ├─ Insert payment     ─►
   │                          ├─ Insert idempotency ─► (key, response_json)
   │                          ├─ COMMIT             │
   │                          │                     │
   │◄─ 201 Created ───────────┤                     │
   │                          │                     │

--- RETRY (rede caiu, cliente não recebeu ACK) ---

   ├─ POST /payments ────────►│                     │
   │  Idempotency-Key: abc123 │                     │
   │                          ├─ SELECT ... WHERE key='abc123'  ──►
   │                          │◄─ found: 201 + body ◄──
   │◄─ 201 Created (mesma!) ──┤  (sem reprocessar)  │

Implementação Postgres + FastAPI (enxuta mas production-shaped):

python
# idempotency.py — pattern completo com locking e TTL
from fastapi import FastAPI, Header, HTTPException, Request
from sqlalchemy import text
import json, hashlib

app = FastAPI()

IDEMPOTENCY_TTL_HOURS = 24  # Stripe guarda 24h, ajuste por caso de uso

@app.post("/payments")
async def create_payment(
    request: Request,
    idempotency_key: str = Header(..., alias="Idempotency-Key"),
):
    body = await request.json()
    body_hash = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()

    async with db.begin() as tx:
        # Lock da key — evita duas réplicas da API processarem o mesmo key em paralelo
        row = await tx.execute(
            text("""
                SELECT status_code, response_body, request_hash
                FROM idempotency_keys
                WHERE key = :k AND created_at > NOW() - INTERVAL '24 hours'
                FOR UPDATE
            """),
            {"k": idempotency_key},
        )
        existing = row.fetchone()

        if existing:
            # Proteção: mesma key com body diferente = erro 422 (Stripe faz isso)
            if existing.request_hash != body_hash:
                raise HTTPException(422, "Idempotency-Key reuse with different payload")
            return {"status": existing.status_code, "body": existing.response_body}

        # Executa a lógica real do pagamento
        payment = await create_payment_logic(body)
        response = {"id": payment.id, "status": "created"}

        # Grava a resposta pra próxima chamada com a mesma key
        await tx.execute(
            text("""
                INSERT INTO idempotency_keys
                  (key, request_hash, status_code, response_body, created_at)
                VALUES (:k, :h, 201, :r, NOW())
            """),
            {"k": idempotency_key, "h": body_hash, "r": json.dumps(response)},
        )
        return response
🚨
Armadilha #1: in-flight requests. Se dois retries chegam ao mesmo tempo em réplicas diferentes da API, sem FOR UPDATE os dois não acham a key existente e ambos executam. Sempre lock (row-level no Postgres ou SET NX no Redis com TTL). A Stripe usa locking explícito em todas as rotas idempotentes.
⚠️
Armadilha #2: body diferente com mesma key. Um bug no cliente pode reusar a key com body alterado. Guarde o hash do body e rejeite divergências com 422. Evita cenários em que o cliente "acha" que fez uma coisa mas o servidor tem outra.

Backoff exponencial com jitter (a AWS tem paper sobre isso)

Quando um retry é necessário, como esperar importa muito. A evolução histórica:

EstratégiaFórmulaProblema
Retry imediatosleep = 0Thundering herd instantâneo — pior das opções.
Retry fixosleep = 1sEspalha mas não cede carga se o serviço está estressado.
Backoff linearsleep = n * 1sMuito lento pra propagar; ainda sincroniza se N clientes erram juntos.
Backoff exponencialsleep = base * 2^nBom, mas todos retriam nos mesmos instantes (2s, 4s, 8s).
Exp + full jitter (AWS)sleep = random(0, base * 2^n)Melhor: espalhado no tempo, decresce a carga exponencialmente.
Exp + equal jittersleep = base*2^n/2 + random(0, base*2^n/2)Full jitter com piso mínimo.
💡
Paper seminal: Exponential Backoff And Jitter (AWS Architecture Blog, 2015). Conclusão experimental: full jitter (ou "decorrelated jitter") tem throughput efetivo superior em quase todos os cenários. Use.

Implementação Python com tenacity:

python
# retry_with_jitter.py
from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception_type
import httpx

@retry(
    stop=stop_after_attempt(5),                       # até 5 tentativas
    wait=wait_random_exponential(                     # full jitter
        multiplier=1,                                 # base em segundos
        max=30,                                       # cap em 30s
    ),
    retry=retry_if_exception_type(httpx.TransportError),
    reraise=True,
)
async def call_payment_gateway(payload: dict):
    async with httpx.AsyncClient(timeout=5.0) as client:
        resp = await client.post(
            "https://gateway.example.com/charge",
            json=payload,
            headers={"Idempotency-Key": payload["op_id"]},  # sempre junto!
        )
        resp.raise_for_status()
        return resp.json()

Implementação TypeScript manual (quando não quer dep):

typescript
// backoff.ts
export async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  opts: { maxAttempts?: number; baseMs?: number; capMs?: number } = {},
): Promise<T> {
  const { maxAttempts = 5, baseMs = 200, capMs = 30_000 } = opts;
  let lastErr: unknown;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (!isRetryable(err)) throw err;              // 4xx não-429: não retria
      if (attempt === maxAttempts - 1) break;

      const exp = Math.min(capMs, baseMs * 2 ** attempt);
      const jitter = Math.random() * exp;            // full jitter
      await new Promise((r) => setTimeout(r, jitter));
    }
  }
  throw lastErr;
}

function isRetryable(err: unknown): boolean {
  // network + 5xx + 429 = retriáveis. 4xx normais = não.
  if (err instanceof TypeError) return true;           // network
  const status = (err as { status?: number })?.status;
  return status === 429 || (!!status && status >= 500);
}
⚠️
Nem tudo deve ser retriado. Retries cegos em 4xx (400, 401, 422) só desperdiçam quota. Regra: retria em timeouts, erros de rede, 429 (respeitando Retry-After header) e 5xx. 4xx são problema seu, não do servidor.

Circuit Breaker: quando parar de retriar

Imagine que o pagamento gateway está 100% fora há 2 minutos. Sua API continua aceitando requests, cada uma esperando 5s de timeout + 5 retries com backoff = 30s. Threads começam a acumular, pool de conexão esgota, sua API morre por tabela. Circuit breaker quebra esse ciclo.

              falhas > threshold
   ┌─────────────────────────────────────────┐
   ▼                                          │
┌─────────┐    recuperou?        ┌─────────────────┐
│ CLOSED  │◄─────────────────────│   HALF-OPEN     │
│(normal) │                      │(deixa 1 passar) │
└─────────┘─────────────────────►└─────────────────┘
     ▲                                          ▲
     │                                          │
     │            timeout decorre               │
     │         (ex: 60s sem tentar)             │
     │                                          │
     └──────────► ┌─────────┐ ─────────────────┘
                  │  OPEN   │
                  │(rejeita │
                  │ tudo)   │
                  └─────────┘

Implementação básica em Python:

python
# circuit_breaker.py — versão educativa
import time
from enum import Enum

class State(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

class CircuitBreaker:
    def __init__(self, fail_threshold=5, reset_timeout_s=60):
        self.state = State.CLOSED
        self.fail_count = 0
        self.fail_threshold = fail_threshold
        self.reset_timeout_s = reset_timeout_s
        self.opened_at: float | None = None

    def call(self, fn, *args, **kwargs):
        if self.state == State.OPEN:
            if time.time() - self.opened_at > self.reset_timeout_s:
                self.state = State.HALF_OPEN   # deixa 1 tentativa passar
            else:
                raise CircuitOpenError("circuit is open")

        try:
            result = fn(*args, **kwargs)
        except Exception:
            self._on_failure()
            raise
        self._on_success()
        return result

    def _on_success(self):
        self.fail_count = 0
        self.state = State.CLOSED

    def _on_failure(self):
        self.fail_count += 1
        if self.fail_count >= self.fail_threshold:
            self.state = State.OPEN
            self.opened_at = time.time()

class CircuitOpenError(Exception): ...

Em produção use bibliotecas prontas: resilience4j (Java), opossum (Node), pybreaker ou aiobreaker (Python), service meshes (Istio, Linkerd) fazem no proxy. Features extras: sliding window de falhas (não só contador), bulkheads (isola chamadas por pool), fallback values.

At-most-once, at-least-once, exactly-once

As três garantias de entrega que todo engenheiro distribuído precisa saber de cor:

GarantiaSignificadoCusto
At-most-oncePode perder mensagens, nunca duplicaSem retries, fire-and-forget. UDP, metrics.
At-least-onceNunca perde, pode duplicar (retry + dedup do consumer)Broker guarda até ACK. SQS, RabbitMQ, Kafka (default).
Exactly-once (teórico)Nenhuma perda, nenhuma duplicaçãoImpossível end-to-end em rede real (Two Generals Problem).
Effectively-once (prático)At-least-once + consumer idempotente → efeito = exactly-oncePadrão em sistemas sérios. Kafka EOS, Pulsar EOS dentro do broker.
💡
Two Generals Problem (1975): dois exércitos precisam atacar juntos por mensageiros que podem ser capturados. Prova-se que nenhum número finito de confirmações resolve a incerteza. Tradução pra rede: você nunca sabe se a ACK final chegou. Por isso exactly-once puro é mito; effectively-once é a realidade.

Effectively-once na prática:

python
# consumer_dedup.py — at-least-once do broker + dedup local = effectively-once
async def process_message(msg: dict):
    msg_id = msg["id"]  # broker garante que este id é estável

    async with db.begin() as tx:
        # Tenta inserir — se duplicata, constraint falha e a gente ignora
        result = await tx.execute(
            text("""
                INSERT INTO processed_messages (id, processed_at)
                VALUES (:id, NOW())
                ON CONFLICT (id) DO NOTHING
            """),
            {"id": msg_id},
        )
        if result.rowcount == 0:
            log.info("duplicate message, skipping", msg_id=msg_id)
            return

        # Primeira vez vendo essa msg — processa na mesma transação
        await apply_side_effects(msg, tx)

    # Só ACK o broker depois do commit bem-sucedido
    await broker.ack(msg)

Outbox Pattern: publicar eventos sem perder ou duplicar

Problema clássico: você quer criar um pedido E publicar um evento "OrderCreated". Se faz em duas etapas (INSERT + publish), pode crashar no meio — DB tem o pedido, o evento nunca saiu (ou vice-versa).

┌──────────────────────────────────────────┐
│ Transação ATÔMICA no banco               │
│                                          │
│  1. INSERT INTO orders (...)             │
│  2. INSERT INTO outbox (event, payload)  │
│                                          │
│  COMMIT                                  │
└──────────────────────────────────────────┘
              │
              ▼
    ┌──────────────────────┐
    │   RELAY (worker)     │  ← polling ou CDC (Debezium)
    │                      │
    │ SELECT * FROM outbox │
    │ WHERE sent=false     │
    │   ORDER BY id        │
    └──────────────────────┘
              │
              ▼
          Kafka / SQS / RabbitMQ
              │
              ▼
       UPDATE outbox SET sent=true

Por que funciona: a transação é atômica (ACID do banco garante). O relay é idempotente (se crashar entre publicar e marcar como sent, apenas republica — consumer fará dedup). Combinado com ON CONFLICT DO NOTHING no consumer = effectively-once end-to-end.

Decisões reais

📋 API de pagamento que aceita POST — retry do cliente pode duplicar cobrança

Idempotency-Key no header, validação com FOR UPDATE, TTL 24h

Esse é o padrão Stripe e é o esperado no mercado. SDKs (Stripe, Paddle) já geram a key automaticamente. Sem isso, toda integração que você fizer será arriscada, porque retries acontecem.

Alt: Nada (confiar no cliente)Garante duplicidade em redes ruins.

Alt: Hash do payload como keyFunciona se o cliente não muda nada entre retries, mas frágil.

📋 Microserviço chamando outro que está lento/instável

Timeout agressivo + retry com jitter + circuit breaker

Timeout curto evita acumular threads. Retry com jitter lida com transients sem thundering herd. Circuit breaker protege seu serviço quando o upstream morre. As 3 camadas juntas. Service mesh (Istio, Linkerd) faz isso pra você, mas vale saber implementar.

Alt: Só retryFunciona em 80% dos casos, morre no outro 20.

Alt: Só timeoutNão aproveita transients — usuário vê erro que teria resolvido em 200ms.

📋 Publicar evento "user.created" após INSERT no banco

Outbox pattern (transactional outbox)

Garante que evento e dado são atômicos — ou ambos aconteceram ou nenhum. Combine com Debezium/Kafka Connect pra CDC automático, ou um worker simples que faz polling. Alternativas como 'publish depois do commit' são loucamente quebradas quando o processo crasha no meio.

Alt: Publish antes do commitPode publicar e depois abortar a tx — evento fantasma.

Alt: Publish depois do commitPode crashar entre commit e publish — evento nunca sai.

Alt: CDC puro (Debezium)Lê WAL do Postgres. Poderoso mas acopla ao schema da tabela.

Perguntas típicas (Q&A)

Preciso de idempotency key em GET?

Não. GET não muda estado. Você pode chamar 1 ou 100 vezes e o sistema fica igual. Idempotency keys são para operações com side effects (POST, às vezes PATCH).

TTL da idempotency key — quanto tempo?

Depende do caso. Stripe usa 24h — tempo suficiente pra retries longos, curto pra não encher o DB. Pra filas batch que podem ficar presas dias, considere 7d. Nunca infinito, ou você paga armazenamento crescente.

Quando um retry é pior que desistir?

Em 4xx (exceto 429): o servidor tá te dizendo que a request é inválida, retry não resolve. Em operações não-idempotentes sem key: retry duplica. Em endpoint com lógica lenta que timed out no cliente mas ainda está rodando: retry empurra trabalho duplicado.

Kafka EOS (Exactly-Once Semantics) é exactly-once verdadeiro?

Dentro do Kafka, sim (produção idempotente + transações). Mas end-to-end, o consumer ainda precisa ser idempotente se escreve em sistemas externos (DBs, APIs). EOS do Kafka resolve uma parte da equação; o outro lado fica com você.

Circuit breaker fecha sozinho?

Sim — depois do reset_timeout ele vai pra HALF-OPEN, deixa 1 chamada passar. Se sucesso, fecha (CLOSED). Se falha, volta pra OPEN e reseta o relógio. Automático.

Take-aways:
  • Idempotência = f(f(x)) = f(x). Design APIs PUT/DELETE idempotentes nativamente. POST exige idempotency key.
  • Idempotency-Key no header + lock + hash do body + TTL. O padrão Stripe é o padrão.
  • Backoff exponencial com full jitter — sem jitter, thundering herd mata o serviço recuperando.
  • Retria só transients: timeouts, 429 (com Retry-After), 5xx. Nunca 4xx não-429.
  • Circuit breaker protege seu serviço quando o upstream morre. 3 estados: CLOSED, OPEN, HALF-OPEN.
  • Exactly-once end-to-end é mito (Two Generals). O real é effectively-once = at-least-once + dedup.
  • Outbox pattern pra publicar eventos sem perder nem duplicar — atômico com o dado.

Próximo módulo: quando você tem várias operações em serviços diferentes que precisam acontecer juntas — Sagas vs 2PC.

🧩

Quiz rápido

4 perguntas · Acerte tudo e ganhe o badge 🎯 Gabarito

Continue lendo