Um timeout não prova que a operação falhou. O servidor pode ter gravado o registro e perdido a conexão antes de entregar a resposta. Retry e idempotência precisam tratar essa incerteza juntos: repetir a mesma intenção deve recuperar o resultado já registrado, não executar novamente o efeito.
No laboratório local deste artigo, a versão ingênua criou dois registros depois de uma falha simulada após o commit. A versão protegida criou um. O teste usou Python 3.14.2 e SQLite 3.51.2, sem chamadas HTTP, pagamentos, filas ou serviços externos. A falha foi injetada no código; não foi uma interrupção real de rede.
Neste artigo
O instante que muda o diagnóstico
Considere esta sequência: o cliente solicita uma criação; o banco confirma a transação; a resposta se perde; o cliente esgota seu tempo de espera. Para o cliente, o resultado é desconhecido. Para o banco, o registro existe.
Uma consulta posterior pode ajudar, mas procurar “um registro parecido” não substitui identificar a operação. Duas solicitações legítimas podem ter exatamente o mesmo conteúdo. A análise da AWS sobre APIs idempotentes separa a intenção expressa por um identificador da tentativa de inferi-la comparando parâmetros.
O contrato que adotamos no exemplo é pequeno:
- A chave identifica uma operação dentro de um escopo de cliente e ação.
- Mesma chave e mesmo conteúdo retornam o resultado salvo.
- Mesma chave e conteúdo diferente são rejeitados.
- Outra chave representa outra intenção, mesmo que o conteúdo seja igual.
A chave não é credencial. Em produção, o escopo deve vir da autenticação e da operação permitida, não de um campo arbitrário enviado pelo usuário.
Registro do efeito e da chave na mesma transação
O problema não se resolve com um SELECT antes do INSERT. Duas requisições podem consultar ao mesmo tempo, ambas não encontrar a chave e criar dois efeitos. Tampouco basta gravar a chave depois: uma falha entre as duas gravações deixa um efeito sem registro de deduplicação.
Neste teste, a tabela requests tem chave primária composta por escopo e chave. O efeito e a resposta são gravados na mesma transação. Usamos BEGIN IMMEDIATE para serializar escritores no SQLite, cujo modelo de transações permite apenas um escritor simultâneo. Isso é uma escolha do laboratório, não uma receita de concorrência a transplantar sem análise para outro banco.
O núcleo da implementação é este; response contém o identificador criado, e fingerprint é o hash de uma representação JSON determinística do payload sintético:
db.execute("BEGIN IMMEDIATE")
row = db.execute(
"SELECT fingerprint,response FROM requests WHERE scope=? AND key=?",
(scope, key),
).fetchone()
if row:
if row[0] != fingerprint:
raise ValueError("same key, different payload")
response = json.loads(row[1])
else:
cursor = db.execute("INSERT INTO effects(payload) VALUES(?)", (encoded,))
response = {"id": cursor.lastrowid, "status": "created"}
db.execute("INSERT INTO requests VALUES(?,?,?,?)", (
scope, key, fingerprint, json.dumps(response, sort_keys=True),
))
db.execute("COMMIT")
O trecho é o núcleo, não um programa completo: criação das tabelas, abertura e fechamento da conexão e rollback em exceção também fazem parte do experimento. A exceção que simula a resposta perdida acontece depois do COMMIT; antes dele, estaríamos testando outro problema.
Resultado observado e como reproduzir o cenário
O roteiro é reproduzível em um banco descartável: crie effects(id INTEGER PRIMARY KEY, payload TEXT) e requests(scope TEXT, key TEXT, fingerprint TEXT, response TEXT, PRIMARY KEY(scope,key)); envie {"value":42}; force uma exceção depois do commit; repita a chamada com a mesma chave. Conte as linhas e compare o identificador retornado. Na variante ingênua, ignore a tabela de chaves e insira sempre.
| Caso executado | Resultado observado | O que verifica |
|---|---|---|
| Implementação ingênua, resposta perdida e uma repetição | 2 registros | O teste consegue reproduzir a duplicação |
| Implementação protegida, mesma falha e repetição | 1 registro, resposta com id: 1 |
O efeito confirmado pode ser recuperado |
Mesma chave com value: 43 |
Rejeição e contagem mantida em 1 | A chave não aceita uma intenção alterada |
| 8 chamadas em threads, liberadas por uma barreira, com a mesma chave | 1 registro e 8 respostas iguais | Exclusão entre escritores nesse banco local |
| Chave diferente com o mesmo conteúdo | Segundo registro criado | Conteúdo igual não é, por si, duplicata |
O teste concorrente usa conexões separadas, espera de banco de dez segundos e barreira para aproximar o início das chamadas. Ele não mede capacidade, latência de produção ou comportamento de múltiplas regiões. Nenhum resultado de benchmark é inferido desse ensaio.
A resposta guardada também faz parte do contrato
Se a primeira tentativa criou o registro 1, responder à segunda apenas “já existe” pode deixar o cliente sem saber qual registro recuperar. Guardar uma resposta estável torna essa reconciliação explícita. Ainda é preciso definir quais erros serão persistidos, como a API sinalizará conflito de payload e por quanto tempo as chaves serão retidas.
O exemplo não expira chaves. Em um serviço real, apagar uma chave cedo demais permite que uma repetição atrasada volte a criar um efeito. O prazo deve considerar a janela de repetição dos clientes, reprocessamento e retenção aceitável. Não há um número universal que possa ser escolhido apenas porque o banco está crescendo.
O limite importante: efeitos fora do banco
Uma transação SQLite não torna atômico um e-mail, uma chamada de pagamento ou uma gravação em outro serviço. Se você enviar a mensagem antes do commit e depois repetir a operação, ainda pode enviá-la duas vezes. Esses casos exigem contrato próprio no destinatário e, conforme a arquitetura, registro transacional de intenção para processamento posterior.
Da mesma forma, idempotência não autoriza tentativas infinitas. A orientação da AWS sobre timeouts, backoff e jitter trata a carga adicional que tentativas podem impor a uma dependência em falha. Defina prazo total, limite de tentativas e quais respostas permitem repetição.
Isso aprofunda o problema tratado em fallback e confiabilidade em sistemas pequenos: uma alternativa não pode fingir sucesso. Quando há filas, a mesma exigência acompanha o tratamento de confiabilidade entre banco e processamento assíncrono.
No code review, peça três evidências: o erro ocorre depois do efeito, o controle ingênuo realmente duplica e a versão protegida rejeita tanto concorrência duplicada quanto reutilização da chave com outro conteúdo. Só testar duas chamadas sequenciais bem-sucedidas deixa de fora justamente o instante que causou o problema.