Seu serviço quebra no meio de uma requisição POST /charge. O cliente vê um timeout e faz retry. Agora você tem duas cobranças. O cliente está irritado. O banco de dados está consistente. Sua lógica de negócio, não.

Isso não é um edge case. É o comportamento padrão de sistemas distribuídos. Redes dropam packages. Containers são OOM-killed no meio da requisição. Load balancers retornam 502 para requisições que já chegaram ao backend. Se sua API assume “recebi um erro, então a operação definitivamente não aconteceu”, você está construindo uma fábrica de bugs.

A realidade at-least-once da qual você não escapa

HTTP é at-least-once por padrão. TCP faz retry de packages perdidos. Seu cliente HTTP faz retry em timeout. Sua infraestrutura faz retry em 5xx. Cada camada assume que a anterior pode falhar e reenvia.

O problema não são os retries. O problema são operações non-idempotent.

Um GET é seguro para retry porque ler duas vezes é o mesmo que ler uma vez. Um POST /charge ou POST /orders não é. Executar duas vezes cria dois recursos. Executar zero vezes perde uma venda. Você não pode escolher “exactly once” em uma rede não confiável. Você só pode escolher entre “at least once” com deduplication, ou “talvez zero, talvez dois” com corrupção de dados.

Idempotency é como você torna “at least once” seguro.

Como idempotency keys realmente funcionam

A Stripe popularizou esse padrão, mas a ideia é mais antiga. O cliente gera uma chave única (um UUID) e a envia em um header: Idempotency-Key: <uuid>. O servidor armazena a tupla (key, request_body, response) antes de executar o side effect. Se a mesma chave chegar novamente, o servidor retorna a resposta armazenada sem reexecutar a operação.

A ideia central: o servidor deve armazenar a chave antes de fazer o trabalho, não depois. Se você escrever a linha de charge primeiro e quebrar antes de armazenar a chave, o retry cria uma segunda cobrança. O armazenamento da idempotency key e a mutação de negócio devem ser atômicos.

Na prática, isso significa uma de duas coisas:

  1. O idempotency store e o business store compartilham uma database transaction. Você insere em idempotency_keys e charges no mesmo BEGIN ... COMMIT. Se o commit for bem-sucedido, ambos existem. Se falhar, nenhum dos dois existe.

  2. O idempotency store é o business store. Sua tabela charges tem uma coluna client_idempotency_key com uma constraint UNIQUE. O retry falha na verificação de unicidade, e você retorna a linha existente.

A opção 2 é mais simples e é por onde a maioria dos times deve começar.

Uma implementação funcional com armazenamento atômico

Aqui está um servidor Python mínimo, mas completo, usando SQLite. A idempotency key vive na mesma transaction que a mutação de negócio. Se o servidor quebrar depois do COMMIT, o retry encontra o cache. Se quebrar antes do COMMIT, nada é persistido e o retry reexecuta com segurança.

import sqlite3
import json
import uuid

DB_PATH = "/data/inventory.db"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS reservations (
            id TEXT PRIMARY KEY,
            item_id TEXT NOT NULL,
            quantity INTEGER NOT NULL,
            idempotency_key TEXT UNIQUE NOT NULL,
            created_at INTEGER
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS idempotency_responses (
            key TEXT PRIMARY KEY,
            response_body TEXT NOT NULL,
            created_at INTEGER
        )
    """)
    conn.commit()
    return conn

def reserve_inventory(conn, idempotency_key, item_id, quantity):
    # Step 1: Check for an existing response.
    cursor = conn.execute(
        "SELECT response_body FROM idempotency_responses WHERE key = ?",
        (idempotency_key,)
    )
    row = cursor.fetchone()
    if row:
        return json.loads(row[0])

    # Step 2: Do the work inside a single transaction.
    conn.execute("BEGIN")

    # Deduct inventory atomically.
    conn.execute(
        "UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
        (quantity, item_id, quantity)
    )
    if conn.total_changes == 0:
        conn.rollback()
        return {"error": "insufficient inventory"}

    # Record the reservation.
    reservation_id = str(uuid.uuid4())
    conn.execute(
        "INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
        (reservation_id, item_id, quantity, idempotency_key)
    )

    # Cache the response for retries.
    response = {"reservation_id": reservation_id, "status": "reserved"}
    conn.execute(
        "INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
        (idempotency_key, json.dumps(response))
    )

    conn.commit()
    return response

A tabela idempotency_responses é a rede de segurança. A primeira requisição executa a mutação, faz commit do resultado e cacheia a resposta. Toda requisição subsequente com a mesma chave pula o trabalho e retorna o JSON cacheado. A reserva e a entrada de cache são escritas na mesma transaction, então ambas estão visíveis ou ambas estão ausentes.

Onde isso falha: o limite do side effect

Idempotency keys lidam com requisições duplicadas do mesmo cliente. Elas não lidam com requisições duplicadas concorrentes de clientes diferentes. Se dois usuários clicam em “Comprar” no último item em estoque, você ainda precisa de pessimistic locking ou optimistic concurrency control. A verificação available >= ? no exemplo acima é uma forma primitiva disso, mas sistemas reais de inventário precisam de mais.

O problema maior são side effects fora da transaction. Se você cobra um cartão via Stripe, envia um e-mail via SendGrid e escreve no seu banco de dados, a idempotency key só protege a parte do banco de dados. O e-mail pode ser enviado duas vezes. O cartão pode ser cobrado duas vezes se a janela de idempotência da própria Stripe expirar. Segurança verdadeira exige que todo sistema downstream participe.

É por isso que a Stripe aceita sua própria Idempotency-Key na criação de charge. Eles deduplicam na sua camada. Você deve fazer o mesmo na sua. Passe a mesma chave adiante para qualquer serviço downstream idempotente. Para serviços que não suportam isso, envolva a chamada em uma local transaction ou aceite o risco.

Colisões de chaves, TTLs e outras armadilhas operacionais

UUID4 tem 122 bits de aleatoriedade. A probabilidade de colisão é negligenciável para qualquer volume realista. Não use sequential integers, timestamps ou hashed request bodies como chaves. Um UUID gerado pelo cliente é o industry standard por um motivo.

O armazenamento de chaves cresce para sempre a menos que você expire entradas antigas. Defina um TTL: 24 horas é o padrão. Depois disso, delete chaves antigas. Se um cliente fizer retry depois do TTL, ele recebe uma duplicata. Documente isso. A janela de retry e o TTL são um business contract, não um detalhe técnico.

O idempotency store deve ser pelo menos tão disponível quanto a API. Se seu cache Redis estiver fora do ar, você não pode verificar chaves. Alguns times recorrem a “assumir nova requisição”, o que cria duplicatas durante outages. Outros rejeitam a requisição, o que é mais seguro, mas cria um modo de falha diferente. Não existe almoço grátis aqui.

O lado do cliente importa tanto quanto

Idempotency keys do lado do servidor são inúteis se o cliente não as enviar. Toda requisição mutante deve gerar uma chave no call site e fazer retry em timeout com a mesma chave:

import uuid
import requests

def safe_post(url, payload, max_retries=3):
    key = str(uuid.uuid4())
    for attempt in range(max_retries):
        try:
            resp = requests.post(
                url,
                json=payload,
                headers={"Idempotency-Key": key},
                timeout=10,
            )
            return resp
        except requests.Timeout:
            if attempt == max_retries - 1:
                raise
            # Retry with the SAME key. The server deduplicates.

A chave deve ser gerada uma vez por operação lógica, não uma vez por tentativa HTTP. Se você gerar um novo UUID a cada retry, perdeu o ponto. A chave é o contrato que une os retries.

FAQ

E se o cliente for um browser e o usuário atualizar a página?

Uma atualização de página cria um novo contexto JavaScript. A nova requisição recebe uma nova idempotency key a menos que você persista a chave no localStorage ou na URL. A maioria dos times não se preocupa com isso para fluxos não críticos. Para pagamentos, você deveria.

Requisições GET devem usar idempotency keys?

Não. GET já é seguro pelas semantics do HTTP. Idempotency keys são para métodos que alteram estado: POST, PUT, PATCH, DELETE.

Posso usar o hash do request body como idempotency key?

Somente se o body for determinístico e não contenha timestamps ou valores aleatórios. Na prática, UUIDs gerados pelo cliente são mais simples e mais confiáveis.

Por quanto tempo devo manter idempotency keys?

Mais tempo do que sua maior janela de retry do cliente. Se clientes fazem retry por 60 segundos, mantenha as chaves por 24 horas. Se clientes puderem fazer retry amanhã por causa de um batch job, mantenha as chaves por uma semana.

Torne sua primeira requisição idempotente hoje

Adicione um header Idempotency-Key a todo endpoint mutante. Comece com um cache de 24 horas. Use um UUID4. Torne o armazenamento atômico com sua business transaction. Na primeira vez que um container morrer no meio da requisição e o cliente fizer retry, você ficará feliz por ter feito isso. A alternativa é explicar para sua equipe de finanças por que um único cliente tem dezessete cobranças idênticas.