Pular para o conteúdo principal
Todos os Posts
Engineering8 min de leitura

Idempotência É um Protocolo, Não uma Chave

Na primeira vez em que entreguei idempotência como um header UUID e uma consulta no Redis, uma cobrança duplicada escapou uma semana depois. Estas são minhas notas sobre tratar idempotência como um protocolo de quatro partes — deduplicação, determinismo, segurança concorrente, propagação downstream — com uma implementação mínima em Kotlin mais Postgres que se mantém firme sob retry.

Tiarê Balbi BonaminiEngenheiro de Software · Vancouver
2/4

Quatro preocupações que se colapsam em um único header

Lendo o artigo do Brandur sobre chaves no estilo Stripe e a referência da API da Adyen, o mesmo ponto ficava aparecendo: a palavra "idempotência" esconde quatro preocupações separadas, e colapsar todas elas em um único UUID é de onde vêm os bugs.

  • Deduplicação de requisições. Duas requisições idênticas devem produzir um único resultado.
  • Determinismo da operação. A mesma entrada deve produzir o mesmo resultado, enquanto a chave for válida.
  • Segurança de execução concorrente. Duas cópias concorrentes da mesma chave não podem ambas executar o efeito colateral.
  • Propagação downstream. Todo serviço que o handler chama precisa honrar o mesmo contrato.

Um SETNX no Redis cobre a deduplicação no caminho feliz e endereça parcialmente a segurança concorrente. Não faz nada pelas outras duas. Cada lacuna é um incidente de produção à espera da combinação certa de timing de retry, restart de pod e eviction de cache.

Por que só o Redis falhou comigo

A falha concreta que sofri foi mais ou menos assim. O cliente envia um POST com Idempotency-Key: abc. O Pod A ganha o SETNX no Redis, chama o gateway de pagamento, e então crasha antes de escrever a resposta de volta no Redis. O cliente tenta novamente 30ms depois. O Pod B vê a chave no Redis, mas o valor está vazio — a resposta nunca foi armazenada. O caminho de fallback tratou isso como "prestes a terminar" e, depois de uma pequena espera, executou a cobrança novamente.

A causa raiz é que um cache não é uma máquina de estados. Qualquer operação com mais de um desfecho — em progresso, sucesso, falha, expirada — precisa de armazenamento durável com pelo menos três estados explícitos, não de um único bit de "setado ou não setado".

O formato de armazenamento que aguenta

Mover o armazenamento da chave para o Postgres com uma constraint de unicidade mudou a forma do problema. A constraint é a única camada no stack que torna "dois inserts concorrentes, exatamente um vencedor" uma propriedade atômica. Brandur coloca isso diretamente: o índice UNIQUE é "atômico por construção, sem condição de corrida possível".

O schema mínimo ao qual eu sempre voltava:

sql
CREATE TABLE idempotency (
  key         TEXT PRIMARY KEY,
  status      TEXT NOT NULL CHECK (status IN ('IN_PROGRESS','SUCCEEDED','FAILED')),
  body        JSONB,
  started_at  TIMESTAMPTZ NOT NULL,
  finished_at TIMESTAMPTZ
);

O protocolo em cima disso é uma escrita em três fases. INSERT primeiro, para reivindicar a chave. Execute o trabalho. UPDATE com o resultado. Se o INSERT entrar em conflito, outra requisição já está segurando a chave — dependendo do estado e da idade da chave, ou retorne a resposta armazenada, retorne 409 Conflict, ou recupere uma entrada travada.

Vale a pena olhar a máquina de estados por trás dessas três fases antes de ler o código.

Aqui está a coisa inteira em Kotlin, usando o JdbcTemplate do Spring. Uma classe, um arquivo.

kotlin
import org.springframework.dao.DuplicateKeyException
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional
import java.sql.Timestamp
import java.time.Instant

@Service
class IdempotencyGuard(private val db: JdbcTemplate) {

    @Transactional(isolation = Isolation.SERIALIZABLE)
    fun <T : Any> runOnce(key: String, staleAfterSec: Long = 30, work: () -> T): T {
        val now = Timestamp.from(Instant.now())
        try {
            db.update(
                "INSERT INTO idempotency(key,status,started_at) VALUES(?, 'IN_PROGRESS', ?)",
                key, now,
            )
        } catch (_: DuplicateKeyException) {
            val row = db.queryForMap(
                "SELECT status, body, started_at FROM idempotency WHERE key = ? FOR UPDATE",
                key,
            )
            when (row["status"] as String) {
                "SUCCEEDED" -> {
                    @Suppress("UNCHECKED_CAST")
                    return row["body"] as T
                }
                "IN_PROGRESS" -> {
                    val started = (row["started_at"] as Timestamp).toInstant()
                    if (Instant.now().isBefore(started.plusSeconds(staleAfterSec))) {
                        throw ConflictException("Operation for $key is in progress")
                    }
                }
            }
            db.update("UPDATE idempotency SET status='IN_PROGRESS', started_at=? WHERE key=?", now, key)
        }

        return try {
            val result = work()
            db.update(
                "UPDATE idempotency SET status='SUCCEEDED', body=?::jsonb, finished_at=? WHERE key=?",
                result.toString(), Timestamp.from(Instant.now()), key,
            )
            result
        } catch (e: Exception) {
            db.update(
                "UPDATE idempotency SET status='FAILED', finished_at=? WHERE key=? ",
                Timestamp.from(Instant.now()), key,
            )
            throw e
        }
    }
}

class ConflictException(msg: String) : RuntimeException(msg)

Plugue isso em uma app Spring Boot, crie a tabela e rode com ./gradlew bootRun. O comportamento que verifiquei em um projeto descartável: dispare dez requisições paralelas com a mesma chave em um controller que delega para runOnce, e confirme que exatamente um corpo de closure executa, um recupera a resposta armazenada, e os outros veem 409.

Três detalhes não óbvios. O INSERT acontece antes de qualquer efeito colateral, então a constraint de unicidade — não a lógica de aplicação — impõe exclusão mútua. O FOR UPDATE no caminho de conflito bloqueia uma consulta concorrente até que a primeira transação comite, assim o segundo chamador nunca lê uma linha escrita pela metade. A janela de staleAfterSec é a saída de emergência para chaves que ficam travadas porque um pod crashou entre reivindicação e resultado; sem ela, uma linha IN_PROGRESS pode travar a chave para sempre.

Uma ressalva que anotei nos meus próprios testes: work() roda dentro da transação SERIALIZABLE aqui. Isso é ok para mutações locais de DB mas não para chamadas externas lentas. Para essas, eu divido o fluxo em duas transações e avanço uma coluna recovery_point entre elas, seguindo o padrão de fases atômicas do Brandur. A versão de transação única é o núcleo pedagógico; a versão de duas transações é o que eu de fato faço deploy.

TTLs são estruturais, não decoração

Demorei mais do que deveria para internalizar que retenção é parte do contrato, não um botão de limpeza. Um artigo do DZone sobre perda de dados de idempotência por phantom write faz o ponto de forma afiada: quando um registro de idempotência expira no meio de um replay, cada mensagem reprocessada parece nova e é processada novamente.

Minha regra aproximada a partir da leitura do material e da observação de comportamento localmente: a janela de retenção precisa exceder a maior janela possível de retry que o endpoint enfrenta.

  • Clientes HTTP com orçamentos de retry de 24 horas: mantenha as chaves por no mínimo 24 horas. É o que a Stripe documenta.
  • Consumidores Kafka ou replayers de webhook com janelas de replay de uma semana: mantenha as chaves por no mínimo 7 dias.
  • Lançamentos de ledger e contabilidade de partidas dobradas: nunca expire. A chave se torna parte do registro permanente.

A armadilha é usar um único TTL curto (frequentemente o default do Redis) para os três, porque é a mesma ferramenta para os três.

O buraco downstream

A falha que ficou me mordendo após a reescrita para Postgres foi essa. O serviço de pagamento era idempotente. Ele chamava um serviço de e-mail para enviar um recibo. O serviço de e-mail nunca tinha ouvido falar de chaves de idempotência. Um retry chegava, o serviço de pagamento retornava a resposta armazenada, mas o e-mail só tinha saído se a primeira tentativa tivesse sobrevivido até aquela linha — e saía duas vezes se um crash forçasse ambas as tentativas a passarem pelo bloco de work().

O protocolo só se mantém se cada hop o honrar. Dois padrões das minhas notas, dependendo do downstream:

Se o downstream aceita um header de idempotência (Stripe, Adyen, webhooks modernos), repasse a chave do chamador ou derive uma chave filha determinística a partir dela. Se o downstream não aceita, mova o efeito colateral para trás de um outbox transacional: escreva a linha de domínio e uma linha de outbox dentro da mesma transação de DB, e tenha um worker drenando o outbox com sua própria deduplicação. A deduplicação do worker é trivial porque cada linha de outbox tem uma chave primária.

Qualquer um dos caminhos te dá a mesma propriedade: o contrato atravessa cada fronteira onde um efeito colateral acontece, ou um mecanismo local (outbox, fencing token) fornece a mesma garantia.

Por que isso importa mais agora

O Spring Framework 7.0 chegou em novembro de 2025 com @Retryable e @ConcurrencyLimit movidos para o framework core, sem necessidade da dependência spring-retry. Tornar retries cidadãos de primeira classe é bom. Também silenciosamente transforma qualquer handler não idempotente em um gerador de escritas duplicadas no momento em que alguém estampa @Retryable(maxRetries = 3) nele. As release notes mencionam backoff e timeout; elas não lembram o autor de que o método por baixo agora precisa de um protocolo de idempotência em torno de qualquer mutação de estado.

O que eu faço agora na prática

  • Uso a constraint de unicidade do banco como primitiva de exclusão mútua. Não Redis, não lock distribuído.
  • Modelo a operação explicitamente como IN_PROGRESS para SUCCEEDED ou FAILED, com uma política de stale-after para recuperar linhas travadas.
  • Configuro a janela de retenção para a maior janela de retry que pode alcançar o endpoint. Para movimentação de dinheiro, nunca expira.
  • Repasso ou derivo uma chave de idempotência para cada downstream que aceita uma. Para os demais, uso um outbox transacional.
  • Mantenho o fluxo pequeno o suficiente para raciocinar sobre ele em uma página: um INSERT, um FOR UPDATE, um UPDATE.

Recorra a esse protocolo quando o handler escreve em um banco de dados, chama um provedor de pagamento ou mensageria, ou fan-out para mais de um downstream. Pule para leituras puras e para operações naturalmente idempotentes, como escrever um valor conhecido em uma chave conhecida. Nunca pule para qualquer coisa que movimenta dinheiro.

Continue lendo

Curtindo? Talvez goste disso aqui.

Nada parecido — quer tentar outro ângulo?

Isso foi útil?

Deixe uma avaliação ou uma nota rápida — me ajuda a melhorar.