Idempotência em APIs distribuídas: chaves, persistência e janelas de validade
Como garantir que uma operação de escrita repetida por retry produza exatamente um efeito, com Idempotency-Key, armazenamento transacional e tratamento de concorrência.
Por Alexandre Martins · · 7 min de leitura
Em sistemas distribuídos, a falha mais comum não é a requisição que nunca chega ao servidor. É a requisição que chega, é processada e tem a resposta perdida no caminho de volta. O cliente observa um timeout, aplica a política de retry e envia a mesma operação outra vez. Se a operação for a criação de um pedido ou a captura de um pagamento, o sistema passa a ter dois pedidos ou duas cobranças para uma única intenção do usuário.
Retry é indispensável em redes não confiáveis. O problema não está em repetir, e sim em repetir sem que o servidor consiga reconhecer a repetição. A propriedade que resolve essa tensão é a idempotência.
Como permitir que o cliente repita uma operação de escrita quantas vezes for necessário, com a garantia de que o efeito ocorre uma única vez?
Definição e o que o HTTP já oferece
Uma operação é idempotente quando executá-la uma ou N vezes produz o mesmo estado final no servidor. A RFC 9110, que especifica a semântica do HTTP, classifica GET, HEAD, PUT e DELETE como métodos idempotentes e deixa POST e PATCH de fora. A classificação diz respeito ao efeito pretendido no recurso, não à resposta: um DELETE repetido devolve 404 na segunda vez, mas o estado do servidor é o mesmo.
PUT é idempotente porque substitui a representação completa de um recurso cuja identidade o cliente já conhece. POST cria recursos cuja identidade é atribuída pelo servidor, e por isso cada chamada tende a criar um recurso novo. Uma forma de contornar isso é deixar o cliente escolher o identificador e usar PUT em /pedidos/{id}. Essa abordagem funciona, mas transfere para o cliente a geração de identificadores e não cobre operações que não se encaixam no modelo de recurso, como "capturar pagamento" ou "enviar notificação".
Para esses casos, a solução consolidada é a chave de idempotência enviada em um cabeçalho. O modelo é adotado por provedores de pagamento como a Stripe e está documentado em um rascunho do IETF para o cabeçalho Idempotency-Key.
O modelo da Idempotency-Key
O cliente gera um identificador único para cada intenção de operação, em geral um UUID versão 4, e o envia no cabeçalho Idempotency-Key. Todas as tentativas da mesma intenção, inclusive os retries automáticos, reutilizam a mesma chave. Intenções diferentes recebem chaves diferentes.
Do lado do servidor, o tratamento segue quatro caminhos:
- A chave não existe: a operação é executada e a resposta (status, cabeçalhos relevantes e corpo) é gravada associada à chave.
- A chave existe e o conteúdo da requisição é o mesmo: a resposta gravada é devolvida sem executar nada.
- A chave existe e a operação original ainda está em andamento: o servidor responde 409 Conflict, e o cliente deve tentar novamente após um intervalo.
- A chave existe, mas o conteúdo da requisição é diferente: trata-se de erro de uso do cliente, e a resposta adequada é 422 Unprocessable Content.
O segundo e o quarto caminhos exigem comparar a requisição atual com a original. Guardar o corpo inteiro é caro. A prática comum é armazenar uma impressão digital, por exemplo o SHA-256 do método, do caminho e do corpo canonicalizado.
O escopo da chave também importa. A mesma chave enviada por dois clientes autenticados distintos não pode colidir, então a chave primária do armazenamento costuma ser o par (identificador do cliente, chave).
Ver o fluxograma em texto (Mermaid)
flowchart TD
req["Requisição POST com Idempotency-Key"] --> existe{"Chave já existe\npara este cliente?"}
existe -- "não" --> reserva["INSERT com status EM_ANDAMENTO"]
reserva --> executa["Executa a operação"]
executa --> ok{"Sucesso ou erro 4xx?"}
ok -- "sim" --> grava["Grava status e corpo da resposta"]
grava --> devolve["Devolve a resposta"]
ok -- "erro 5xx" --> libera["Libera a chave"]
libera --> erro["Devolve o erro"]
existe -- "sim" --> impressao{"Mesma impressão\nda requisição?"}
impressao -- "não" --> r422["422 Unprocessable Content"]
impressao -- "sim" --> andamento{"Status\nEM_ANDAMENTO?"}
andamento -- "sim" --> r409["409 Conflict"]
andamento -- "não" --> cache["Devolve a resposta gravada"]
Persistência e concorrência
O armazenamento das chaves precisa de três propriedades: unicidade garantida, atomicidade entre "reservar a chave" e "executar a operação" e expiração. Duas opções cobrem a maioria dos cenários.
A primeira é uma tabela no mesmo banco relacional da aplicação, com uma restrição de unicidade:
CREATE TABLE chave_idempotencia (
cliente_id TEXT NOT NULL,
chave TEXT NOT NULL,
impressao TEXT NOT NULL, -- SHA-256 de método + caminho + corpo
status TEXT NOT NULL, -- EM_ANDAMENTO | CONCLUIDA
resposta_status INT,
resposta_corpo JSONB,
criada_em TIMESTAMPTZ NOT NULL DEFAULT now(),
expira_em TIMESTAMPTZ NOT NULL,
PRIMARY KEY (cliente_id, chave)
);
CREATE INDEX chave_idempotencia_expira_idx ON chave_idempotencia (expira_em);
A restrição de chave primária é o que resolve a concorrência. Se duas requisições com a mesma chave chegam ao mesmo tempo, as duas tentam inserir a linha com status EM_ANDAMENTO. Apenas uma insere. A outra recebe violação de unicidade e responde 409 sem executar a operação. Verificar com SELECT se a chave existe e depois inserir, sem a restrição, abre uma janela em que as duas verificações passam e as duas operações executam. A ordem correta é inserir primeiro e tratar a violação como sinal de duplicidade.
Quando a operação de negócio grava no mesmo banco, a gravação da resposta e a operação podem ocorrer na mesma transação. Se a transação falhar, a chave não fica reservada e a próxima tentativa parte do zero.
A segunda opção é o Redis, com SET NX e TTL. É adequada quando o estado da operação está em outro sistema e a latência importa mais que a durabilidade. O custo é a perda das chaves em caso de falha do Redis e a impossibilidade de incluir a reserva na transação de negócio.
Exemplo em Java com Spring Boot
A implementação a seguir intercepta requisições POST antes do controller. Ela foi reduzida ao núcleo do mecanismo: a serialização da resposta e a extração do cliente autenticado ficam a cargo de componentes já existentes na aplicação.
@Component
public class FiltroIdempotencia extends OncePerRequestFilter {
private final RepositorioChaves repositorio;
private static final Duration VALIDADE = Duration.ofHours(24);
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
String chave = req.getHeader("Idempotency-Key");
if (!"POST".equals(req.getMethod()) || chave == null) {
chain.doFilter(req, res);
return;
}
var requisicao = new RequisicaoCacheada(req); // permite ler o corpo duas vezes
String impressao = Impressao.sha256(req.getMethod(), req.getRequestURI(), requisicao.corpo());
String cliente = Autenticacao.clienteAtual();
// Reserva primeiro. A unicidade da chave primária decide a corrida.
Reserva reserva = repositorio.reservar(cliente, chave, impressao, Instant.now().plus(VALIDADE));
switch (reserva.resultado()) {
case CONCLUIDA_MESMA_IMPRESSAO -> reserva.respostaGravada().escreverEm(res);
case EM_ANDAMENTO -> res.sendError(409, "Operação em andamento para esta chave");
case IMPRESSAO_DIFERENTE -> res.sendError(422, "Chave reutilizada com conteúdo diferente");
case NOVA -> {
var resposta = new RespostaCapturada(res);
try {
chain.doFilter(requisicao, resposta);
repositorio.concluir(cliente, chave, resposta.status(), resposta.corpo());
} catch (RuntimeException e) {
repositorio.liberar(cliente, chave); // falha: a próxima tentativa recomeça
throw e;
}
resposta.copiarPara(res);
}
}
}
}
O método reservar executa um INSERT e, em caso de violação de unicidade, lê a linha existente para decidir entre os três casos de chave conhecida:
public Reserva reservar(String cliente, String chave, String impressao, Instant expiraEm) {
try {
jdbc.update("""
INSERT INTO chave_idempotencia (cliente_id, chave, impressao, status, expira_em)
VALUES (?, ?, ?, 'EM_ANDAMENTO', ?)""", cliente, chave, impressao, Timestamp.from(expiraEm));
return Reserva.nova();
} catch (DuplicateKeyException e) {
var existente = buscar(cliente, chave);
if (!existente.impressao().equals(impressao)) return Reserva.impressaoDiferente();
if (existente.status().equals("EM_ANDAMENTO")) return Reserva.emAndamento();
return Reserva.concluida(existente.resposta());
}
}
Operações que respondem com erro 5xx não devem ser gravadas como concluídas. O cliente vai repetir, e a repetição precisa executar de novo. Erros 4xx de validação, por outro lado, são determinísticos e podem ser gravados.
Consumidores de fila
O mesmo raciocínio vale para quem consome mensagens. Brokers como Kafka, SQS e RabbitMQ entregam com garantia at-least-once, o que significa que a mesma mensagem pode ser processada duas vezes após falha de confirmação. O identificador da mensagem desempenha o papel da Idempotency-Key, e a tabela de mensagens processadas desempenha o papel da tabela de chaves. A gravação do identificador e o efeito da mensagem devem ocorrer na mesma transação. Esse tema volta em artigo próprio sobre semântica de entrega em mensageria.
Desafios
A janela de validade é uma decisão de produto. Chaves que expiram em minutos não cobrem retries de processos em lote, e chaves eternas fazem a tabela crescer sem limite. Provedores de pagamento adotam janelas de 24 horas, e esse valor é um ponto de partida razoável para a maioria das APIs. A limpeza pode ser um job periódico que remove linhas com expira_em no passado, ou particionamento por dia com descarte de partições antigas.
Operações compostas, que chamam outros serviços, precisam propagar a chave ou derivar chaves determinísticas para cada chamada interna. Caso contrário, a idempotência da borda não protege contra duplicidade nas dependências.
Respostas grandes elevam o custo de armazenamento. Uma alternativa é gravar apenas o identificador do recurso criado e reconstruir a resposta a partir dele.
Por fim, a chave só funciona se o cliente a enviar. Para APIs públicas, é prudente rejeitar POSTs sem o cabeçalho em endpoints com efeito financeiro, em vez de aceitá-los silenciosamente.
Boas práticas
- Exija Idempotency-Key em toda operação de escrita que não seja naturalmente idempotente, e documente o formato esperado.
- Reserve a chave com INSERT protegido por restrição de unicidade antes de executar a operação. Não use verificação seguida de inserção.
- Guarde uma impressão digital da requisição e responda 422 quando a chave for reutilizada com conteúdo diferente.
- Grave a resposta na mesma transação do efeito sempre que o armazenamento permitir.
- Não grave respostas 5xx como concluídas. Libere a chave para que a repetição execute.
- Defina a janela de validade a partir da política de retry dos clientes e automatize a limpeza.
- Aplique o mesmo padrão a consumidores de mensagens, usando o identificador da mensagem como chave.
Conclusão
Idempotência não é um detalhe de implementação de um endpoint, e sim um contrato entre cliente e servidor sobre como lidar com a incerteza da rede. O cliente assume a responsabilidade de identificar cada intenção e repetir com a mesma chave. O servidor assume a responsabilidade de reconhecer a repetição, resolver a concorrência com uma restrição de unicidade e devolver a resposta original.
Com esse contrato, o retry deixa de ser um risco e passa a ser o mecanismo padrão de recuperação. O custo é uma tabela a mais e um filtro na borda da aplicação, um preço pequeno diante de um pedido ou de uma cobrança duplicada.
Referências
- IETF, RFC 9110: HTTP Semantics, seção 9.2.2 (Idempotent Methods). https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methods
- IETF, The Idempotency-Key HTTP Header Field (Internet-Draft). https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
- Stripe, Idempotent requests. https://docs.stripe.com/api/idempotent_requests
- PostgreSQL, documentação de constraints (UNIQUE e PRIMARY KEY). https://www.postgresql.org/docs/current/ddl-constraints.html
- Redis, comando SET com NX e EX. https://redis.io/docs/latest/commands/set/
Assuntos: Distributed Systems, API Design, Spring Boot, PostgreSQL, Software Architecture
Continue lendo
- Semântica de entrega em sistemas de mensageria: at-least-once, idempotência e dead-letter queues — Por que o broker entrega mais de uma vez, como o consumidor converte repetição em efeito único com uma tabela de mensagens processadas na mesma transação, e o que fazer com a mensagem que falha sempre.
- Transactional Outbox: publicação confiável de eventos a partir do banco de dados — Como gravar o evento na mesma transação do agregado e entregá-lo ao broker por polling ou por captura do log de transações, com garantia at-least-once, ordem por agregado e deduplicação no consumidor.
- Rate limiting distribuído: algoritmos, implementação atômica e políticas de falha — Como escolher entre janela fixa, janela deslizante, token bucket e leaky bucket, manter o contador correto com várias réplicas usando um script Lua no Redis, responder 429 com os cabeçalhos RateLimit e decidir o que fazer quando o Redis cai.
Quer aplicar isto no seu sistema? Fale com a MFactor: contato ou WhatsApp +55 (19) 99778-9831.