surfaai · parte 5 de 7
Três camadas de idempotência — e a que eu descobri que não existia
07 de outubro de 20267 min de leituraCesar Eduardo Sturmer
O artigo anterior contou o incidente: um aluno apertava F5 na tela de confirmação e ganhava crédito dobrado. Ele terminou prometendo que este aqui destrincharia as três camadas de proteção que ficaram no lugar.
Cumpro a promessa, com uma correção: ao reler o código para escrever este texto, descobri que a terceira camada não existe. Eu escrevi o tratamento dela e nunca criei a coisa que o tratamento espera.
Vou pelas três na ordem, porque a terceira só faz sentido depois de entender por que as duas primeiras não bastam.
Por que uma camada não resolve
Antes das camadas, o modelo de falha. Webhook de pagamento tem entrega at-least-once: a Stripe reenvia o evento se não receber 2xx rápido o bastante. Então o mesmo evento pode chegar duas vezes.
Essa é a parte que todo mundo sabe. A parte que me pegou é que "o mesmo evento duas vezes" é só um dos jeitos de duplicar. Os outros dois:
- eventos diferentes para o mesmo pagamento.
payment_intent.succeededecharge.succeededdescrevem o mesmo dinheiro entrando, com ids distintos. Qualquer proteção baseada em id de evento deixa os dois passarem. - entrega concorrente. Duas cópias do mesmo evento chegando com milissegundos de diferença, em instâncias diferentes da função. Nenhuma das duas vê o registro da outra, porque nenhuma terminou de escrever.
Cada camada pega um desses casos. É por isso que são três e não uma.
Camada 1 — o id do evento
A primeira barreira fica na entrada do handler, antes de qualquer lógica de negócio:
const { data: existingEvent } = await db
.from('stripe_webhook_events')
.select('id, processed')
.eq('stripe_event_id', event.id)
.maybeSingle();
if (existingEvent?.processed) {
return NextResponse.json({ success: true, message: 'Already processed' });
}Repare no processed. Não basta "já vi esse id" — tem que ser "já terminei esse id". Um evento registrado mas não processado precisa ser processado; se eu desistisse só por existir o registro, uma falha no meio do handler deixaria o crédito nunca criado, e a retentativa da Stripe seria descartada.
Logo depois vem o registro, e aqui está a sutileza:
const { data: logEntry } = await db
.from('stripe_webhook_events')
.upsert(
{
stripe_event_id: event.id,
event_type: event.type,
stripe_account: (event as any).account ?? null,
payload: event as any,
processed: false,
},
{ onConflict: 'stripe_event_id' }
)
.select()
.single();upsert com onConflict, não insert. Entre o select acima e esta escrita existe uma janela, e duas entregas concorrentes do mesmo evento caem as duas aqui. Com insert, a segunda estouraria violação de unicidade e o handler morreria com erro — a Stripe tentaria de novo, e eu teria um erro no log para investigar que não era erro nenhum. Com upsert, as duas convergem para a mesma linha e seguem.
Isso resolve o caso do evento repetido. Não resolve os outros dois.
O detalhe dos dois secrets
Um aparte que também me custou tempo. O surfaai recebe eventos da plataforma e das contas Connect no mesmo endpoint, e cada origem assina com um secret diferente. A validação tenta um e cai no outro:
if (platformSecret) {
try {
event = constructWebhookEvent(rawBody, signature, platformSecret);
} catch {
// não é da plataforma — tenta connect
}
}
if (!event && connectSecret) {
try {
event = constructWebhookEvent(rawBody, signature, connectSecret);
isConnectWebhook = true;
} catch {
// nenhum dos dois bateu
}
}Eu acreditei por um tempo que eventos de conta conectada tinham id com prefixo diferente, e que dava para separá-los olhando o id. Não é assim: o id é evt_ nos dois casos. O que distingue é o campo account no evento, e qual secret valida a assinatura. Se você tratar as duas origens como uma, a verificação falha silenciosamente para metade dos eventos — e a mensagem de erro não diz qual dos dois secrets era o certo.
Camada 2 — o estado de negócio
A camada 1 pergunta "já processei esse evento?". A camada 2 pergunta outra coisa: "esse crédito já existe?". São perguntas diferentes, e é essa diferença que pega o caso dos eventos distintos para o mesmo pagamento.
// Idempotência: crédito já foi criado?
if (payment.user_credit_id) {
return;
}
// Re-fetch antes de criar créditos para capturar webhooks concorrentes
// (dois eventos diferentes para o mesmo PI podem passar pelo check acima ao mesmo tempo)
const { data: freshPayment } = await db
.from('payments')
.select('user_credit_id')
.eq('id', payment.id)
.single();
if (freshPayment?.user_credit_id) {
return;
}
await createUserCredits(payment);O re-fetch parece redundante — acabei de checar o mesmo campo. Não é. O payment da primeira checagem foi lido no começo do handler, possivelmente centenas de milissegundos antes, e nesse intervalo o outro evento pode ter criado o crédito e gravado o user_credit_id. O re-fetch encurta a janela para o menor intervalo que eu consigo: entre a leitura e o insert.
Encurta. Não fecha. Duas execuções podem passar as duas pelo re-fetch antes de qualquer uma inserir. É exatamente para esse resto que existe a terceira camada.
Camada 3 — a que não existe
O código final de createUserCredits é este:
if (creditError) {
// 23505 = unique_violation: crédito já criado por webhook concorrente — OK
if ((creditError as any).code === '23505') return;
throw creditError;
}A ideia é a correta, e é a única das três que fecha de verdade. 23505 é o código do Postgres para violação de unicidade. O raciocínio: se dois webhooks passarem por tudo e tentarem inserir ao mesmo tempo, o banco é o único árbitro que não tem janela — ele serializa as duas escritas e a segunda falha. Apanhar essa falha e retornar sem erro é correto, porque o resultado desejado já aconteceu. Não é um erro, é uma corrida perdida com o resultado certo.
O problema: não existe nenhum índice único em user_credits que faça esse erro acontecer. Procurei nas migrations. Tem chave primária, dois CHECK de sanidade nos contadores de crédito, e seis índices — todos não-únicos, para consulta:
idx_user_credits_active
idx_user_credits_expires
idx_user_credits_user_provider
idx_user_credits_user_service
idx_user_credits_service
idx_user_credits_linked_service_idsNenhum deles impede duas linhas iguais. Então aquele if nunca dispara por crédito duplicado. É uma rede de segurança desenhada, comentada, e pendurada no vazio.
E tem um agravante que eu só entendi depois: o erro que pode aparecer ali é outro. Uma política de RLS negando o insert retorna 42501, não 23505. Se eu tivesse me convencido de que "o banco está protegido" por causa das policies, o raciocínio seria duplamente errado — policy decide quem pode escrever, unicidade decide se a linha pode existir. São mecanismos diferentes, com códigos de erro diferentes, resolvendo problemas diferentes. Nenhuma quantidade de RLS produz 23505.
O que isso me ensinou, de novo
O artigo anterior terminou com uma lição: constraint de banco devia ser a primeira coisa, não a última. Eu escrevi aquilo e ainda assim a constraint não estava lá. Escrevi o catch dela e marquei a tarefa como feita.
É o tipo de erro que não aparece em teste nenhum. O código está sintaticamente correto, passa em revisão, e o comentário ao lado dele explica com confiança um mecanismo que não foi ligado. Em teste automatizado, ele passa — porque o caminho que ele protege não é alcançado em teste, é alcançado por concorrência em produção. E quando for alcançado, vai falhar silenciosamente: o segundo insert vai funcionar, e vão existir dois créditos.
A pergunta que eu levo daqui não é "escrevi o tratamento?". É "o que faz esse erro acontecer, e eu criei essa coisa?". Todo catch de um código de erro específico é uma afirmação sobre o estado do mundo. Essa afirmação precisa ser verificada, não assumida.
Fica por fazer: o índice único. Não é só um CREATE UNIQUE INDEX — é preciso primeiro descobrir se já existem duplicatas em produção, porque a criação falha se existirem, e decidir qual é a chave de verdade (o pagamento? o par pagamento-serviço?). Quando eu resolver isso, esta série ganha um apêndice.
A regra
Três camadas, três perguntas distintas: já processei este evento?, este efeito já existe?, o banco aceita este efeito duas vezes? As duas primeiras são otimizações — reduzem a janela, e são as que você vai escrever primeiro porque são as fáceis. A terceira é a única que fecha, e é a única que não depende de você ter pensado em todos os caminhos do código.
Remova qualquer uma e o buraco reabre. Mas remova a terceira e ele reabre sem você perceber — que é, até eu escrever este artigo, exatamente onde o surfaai estava.