Blog

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

Fluxo de uma requisição com Idempotency-Key. Cliente envia POST com a chave; a API consulta o armazenamento de chaves; na primeira vez encaminha ao serviço e grava a resposta; nas repetições devolve a resposta gravada.

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:

  1. A chave não existe: a operação é executada e a resposta (status, cabeçalhos relevantes e corpo) é gravada associada à chave.
  2. A chave existe e o conteúdo da requisição é o mesmo: a resposta gravada é devolvida sem executar nada.
  3. 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.
  4. 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).

Decisão da API ao receber uma chave. Fluxograma com os quatro caminhos: chave inexistente (processa e grava), chave existente com mesma impressão digital (devolve resposta gravada), chave em processamento (409) e chave reutilizada com corpo diferente (422).
Decisão da API ao receber uma chave. Fluxograma com os quatro caminhos: chave inexistente (processa e grava), chave existente com mesma impressão digital (devolve resposta gravada), chave em processamento (409) e chave reutilizada com corpo diferente (422).
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

  1. Exija Idempotency-Key em toda operação de escrita que não seja naturalmente idempotente, e documente o formato esperado.
  2. 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.
  3. Guarde uma impressão digital da requisição e responda 422 quando a chave for reutilizada com conteúdo diferente.
  4. Grave a resposta na mesma transação do efeito sempre que o armazenamento permitir.
  5. Não grave respostas 5xx como concluídas. Libere a chave para que a repetição execute.
  6. Defina a janela de validade a partir da política de retry dos clientes e automatize a limpeza.
  7. 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

Assuntos: Distributed Systems, API Design, Spring Boot, PostgreSQL, Software Architecture

Continue lendo

Quer aplicar isto no seu sistema? Fale com a MFactor: contato ou WhatsApp +55 (19) 99778-9831.