Idempotência e Retries: o antídoto pra rede que quebra
- ⬜🗳️ Consensus e Raft: como nós discordam e chegam a acordo(Sistemas Distribuídos)
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ção | Idempotente? | Exemplo |
|---|---|---|
| GET | Sim (sem side effects) | GET /users/42 |
| PUT | Sim (substitui por completo) | PUT /users/42 {"name":"Ana"} |
| DELETE | Sim (se já apagado, continua apagado) | DELETE /users/42 |
| POST (sem idempotency key) | Não | POST /payments → cria novo pagamento cada chamada |
| POST (com idempotency key) | Sim (com key + dedup server-side) | POST /payments com Idempotency-Key: uuid |
| Incremento: counter += 1 | Não | Repetir → incrementa de novo |
| Set absoluto: counter = 42 | Sim | Qualquer número de repetições → 42 |
| Compare-and-swap (CAS) | Sim (primeira aplica, demais falham sem erro) | UPDATE ... WHERE version=X |
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:
- Busca a key em storage (Redis, Postgres)
- Se existe: retorna a resposta armazenada — mesma exata que foi enviada antes
- 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):
# 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 responseFOR 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.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égia | Fórmula | Problema |
|---|---|---|
| Retry imediato | sleep = 0 | Thundering herd instantâneo — pior das opções. |
| Retry fixo | sleep = 1s | Espalha mas não cede carga se o serviço está estressado. |
| Backoff linear | sleep = n * 1s | Muito lento pra propagar; ainda sincroniza se N clientes erram juntos. |
| Backoff exponencial | sleep = base * 2^n | Bom, 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 jitter | sleep = base*2^n/2 + random(0, base*2^n/2) | Full jitter com piso mínimo. |
Implementação Python com tenacity:
# 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):
// 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);
}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:
# 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:
| Garantia | Significado | Custo |
|---|---|---|
| At-most-once | Pode perder mensagens, nunca duplica | Sem retries, fire-and-forget. UDP, metrics. |
| At-least-once | Nunca perde, pode duplicar (retry + dedup do consumer) | Broker guarda até ACK. SQS, RabbitMQ, Kafka (default). |
| Exactly-once (teórico) | Nenhuma perda, nenhuma duplicação | Impossível end-to-end em rede real (Two Generals Problem). |
| Effectively-once (prático) | At-least-once + consumer idempotente → efeito = exactly-once | Padrão em sistemas sérios. Kafka EOS, Pulsar EOS dentro do broker. |
Effectively-once na prática:
# 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=truePor 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
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 key — Funciona se o cliente não muda nada entre retries, mas frágil.
📋 Microserviço chamando outro que está lento/instável
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ó retry — Funciona em 80% dos casos, morre no outro 20.
Alt: Só timeout — Não aproveita transients — usuário vê erro que teria resolvido em 200ms.
📋 Publicar evento "user.created" após INSERT no banco
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 commit — Pode publicar e depois abortar a tx — evento fantasma.
Alt: Publish depois do commit — Pode 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.
- 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