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.succeeded e charge.succeeded descrevem 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_ids

Nenhum 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.

← Todos os artigos