Quando a chamada para criar uma cobrança no gateway estoura o timeout, você não sabe se a cobrança foi criada. Repetir a requisição sem cuidado é o caminho mais curto para cobrar o cliente duas vezes. A solução que uso é tratar o timeout como estado desconhecido, gerar uma chave de idempotência antes da chamada, persistir a tentativa no banco e resolver a dúvida consultando o provedor, nunca chutando.
O assunto parece primo do processamento de webhooks, mas está do outro lado do fio. No post sobre webhooks de pagamento duplicados e fora de ordem o problema é o provedor te chamar duas vezes. Aqui é você que chama o provedor e precisa garantir que duas requisições virem uma única cobrança.
Por que timeout em pagamento não é a mesma coisa que erro?
Toda chamada HTTP para um gateway tem três desfechos possíveis, não dois: deu certo, deu errado ou não sei. O terceiro é o que quebra sistemas financeiros, porque o código costuma tratar qualquer exceção como "deu errado" e oferecer ao usuário um botão de tentar de novo.
O timeout pode ter acontecido em vários pontos: antes da requisição sair da sua máquina, depois de o provedor receber e criar a cobrança mas antes de responder, ou no meio do caminho de volta. Do seu lado, tudo isso aparece igual: uma ConnectionException no client HTTP do Laravel.
Por isso, a primeira decisão é classificar cada resultado pelo que ele permite concluir:
| Resultado da chamada | O que dá para concluir | Ação |
|---|---|---|
| Falha ao conectar (DNS, conexão recusada) | A requisição provavelmente não chegou | Retry com a mesma chave |
| Timeout de leitura | Desconhecido | Marcar unknown e consultar |
| 2xx | Cobrança criada | Gravar ID do provedor |
| 4xx de validação (dados inválidos, cartão recusado) | Falha definitiva | Marcar failed |
| 409 / conflito de idempotência | Outra requisição com a mesma chave em andamento | Esperar e consultar |
| 429 | Rate limit, nada foi processado | Retry com backoff |
| 5xx | Desconhecido | Marcar unknown e consultar |
Repare que eu trato 5xx como desconhecido, não como falha. Um 500 do provedor pode ter acontecido depois da cobrança gravada do lado dele. Na dúvida, o estado é unknown.
O que é uma chave de idempotência e onde ela deve nascer?
Chave de idempotência é um identificador único que você envia junto da requisição para que o provedor reconheça repetições e devolva o mesmo resultado em vez de executar a operação de novo. A Stripe aceita isso no header Idempotency-Key; outros provedores oferecem algo parecido ou um campo de referência externa no corpo.
O erro mais comum é gerar a chave no momento da chamada HTTP, tipo Str::uuid() dentro do método que faz o post. Aí cada retry ganha uma chave nova e a idempotência vira enfeite. A chave precisa representar a intenção ("cobrar o pedido 123 neste valor"), não a requisição. Ela nasce antes da chamada, fica gravada no banco e é reaproveitada em todos os retries daquela intenção.
As regras que sigo:
- Uma chave por tentativa de cobrança, gravada antes de qualquer I/O externo.
- A mesma chave em todos os retries daquela tentativa, com exatamente o mesmo payload.
- Uma chave nova só quando a tentativa anterior foi resolvida como falha definitiva.
- Nunca derivar a chave só do ID do pedido: se a primeira tentativa falhou de verdade (cartão recusado), a segunda precisa de outra chave, senão o provedor devolve a recusa antiga.
Como modelar a tentativa de cobrança no banco?
Eu separo a cobrança (o que o domínio quer) da tentativa (cada vez que eu falo com o provedor para realizar aquela cobrança). Uma cobrança pode ter várias tentativas ao longo do tempo, mas só uma aberta por vez.
Schema::create('charge_attempts', function (Blueprint $table) { $table->id(); $table->foreignId('charge_id')->constrained(); $table->uuid('idempotency_key')->unique(); $table->string('provider'); $table->string('status'); // pending, succeeded, failed, unknown $table->string('provider_charge_id')->nullable()->unique(); $table->char('payload_hash', 64); $table->unsignedSmallInteger('http_attempts')->default(0); $table->json('last_error')->nullable(); $table->timestamp('resolved_at')->nullable(); $table->timestamps(); }); DB::statement(" CREATE UNIQUE INDEX charge_attempts_one_open_per_charge ON charge_attempts (charge_id) WHERE status IN ('pending', 'unknown') ");
O índice único parcial do PostgreSQL é o que impede duas tentativas abertas para a mesma cobrança, mesmo com dois workers ou dois cliques simultâneos. Não confio em if no código para isso. O payload_hash serve para garantir que um retry mande exatamente o mesmo corpo: provedores como a Stripe rejeitam uma chave reutilizada com parâmetros diferentes, e prefiro pegar esse bug antes de sair da aplicação.
Os status das tentativas seguem as mesmas regras de transição que descrevi em máquina de estados para status de pagamento: unknown pode virar succeeded ou failed, mas succeeded nunca volta para unknown porque um job atrasado resolveu sobrescrever.
Como enviar a cobrança com retry seguro no Laravel?
O fluxo tem três fases: abrir a tentativa dentro de uma transação curta, fazer a chamada HTTP fora de qualquer transação e gravar o resultado. Segurar uma transação do banco aberta enquanto espera 15 segundos de resposta do gateway é pedir lock contention.
final class SubmitCharge { public function handle(Charge $charge): ChargeAttempt { $payload = [ 'amount' => $charge->amount_cents, 'currency' => 'brl', 'customer' => $charge->customer->provider_customer_id, 'metadata' => ['charge_id' => $charge->id], ]; $attempt = $this->openAttempt($charge, $payload); if ($attempt->status !== 'pending') { return $attempt; // já existe tentativa em unknown sendo resolvida } $attempt->increment('http_attempts'); try { $response = Http::withToken(config('services.stripe.secret')) ->withHeaders(['Idempotency-Key' => $attempt->idempotency_key]) ->connectTimeout(3) ->timeout(15) ->asForm() ->post('https://api.stripe.com/v1/payment_intents', $payload); } catch (ConnectionException $e) { return $this->markUnknown($attempt, $e->getMessage()); } return match (true) { $response->successful() => $this->markSucceeded($attempt, $response->json('id')), $response->status() === 429, $response->status() === 409, $response->serverError() => $this->markUnknown($attempt, $response->body()), default => $this->markFailed($attempt, $response->json()), }; } private function openAttempt(Charge $charge, array $payload): ChargeAttempt { $hash = hash('sha256', json_encode($payload)); $open = $charge->attempts()->whereIn('status', ['pending', 'unknown'])->first(); if ($open) { if ($open->payload_hash !== $hash) { throw new LogicException('Payload mudou com tentativa aberta.'); } return $open; } return $charge->attempts()->create([ 'idempotency_key' => (string) Str::uuid(), 'provider' => 'stripe', 'status' => 'pending', 'payload_hash' => $hash, ]); } private function markUnknown(ChargeAttempt $attempt, string $error): ChargeAttempt { $attempt->update(['status' => 'unknown', 'last_error' => ['message' => $error]]); ResolveUnknownAttempt::dispatch($attempt->id)->delay(now()->addSeconds(30)); return $attempt; } }
No código real isso fica atrás de um adapter do gateway, não com a URL da Stripe no meio do serviço, mas deixei explícito para mostrar onde a chave entra. Se dois requests concorrentes passarem do first() ao mesmo tempo, o índice parcial faz o segundo create lançar UniqueConstraintViolationException; nesse caso eu releio a tentativa aberta e sigo com ela.
Sobre o Http::retry() do Laravel: ele funciona, mas eu evito usar para criação de cobrança. Ele esconde quantas vezes a requisição saiu e mistura retry imediato com uma situação que pede consulta. Prefiro uma tentativa HTTP por execução e deixar a repetição para um job com backoff explícito, onde consigo registrar cada passo.
Por que repetir a requisição nem sempre resolve o estado desconhecido?
A intuição é: "tenho idempotência, então é só reenviar com a mesma chave e ver o que o provedor responde". Às vezes funciona, mas tem duas pegadinhas.
A primeira: alguns provedores, como a Stripe, guardam a resposta da primeira requisição associada à chave, inclusive quando ela foi um erro 500, e devolvem a mesma resposta nas repetições. Reenviar com a mesma chave não vai te dar um resultado diferente; você precisa consultar o recurso.
A segunda: chaves de idempotência têm validade no provedor. A Stripe, por exemplo, pode descartar chaves depois de 24 horas. Se o seu job de resolução ficar parado por um incidente e reenviar a requisição dois dias depois, a chave já não protege nada e você cria uma cobrança nova.
Por isso a resolução de unknown é por consulta, não por reenvio. Toda cobrança sai com um identificador meu (no exemplo, metadata.charge_id, ou um campo de referência externa quando o provedor tem), e é por ele que procuro depois.
Como resolver uma tentativa em estado unknown?
O job de resolução pergunta ao provedor se existe uma cobrança com aquela referência e age conforme a resposta:
final class ResolveUnknownAttempt implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable; public int $tries = 10; public function __construct(public int $attemptId) {} public function backoff(): array { return [30, 60, 120, 300, 600, 1800]; } public function handle(PaymentGateway $gateway): void { $attempt = ChargeAttempt::findOrFail($this->attemptId); if ($attempt->status !== 'unknown') { return; // webhook ou outro job já resolveu } $found = $gateway->findChargeByReference($attempt->charge_id, $attempt->created_at); if ($found) { $attempt->resolveAs($found->status, $found->id); return; } if ($attempt->created_at->lt(now()->subMinutes(30))) { $attempt->resolveAs('failed', null); return; } $this->release($this->backoff()[$this->attempts() - 1] ?? 1800); } }
Alguns pontos que aprendi errando:
- "Não encontrado" não é prova de que não existe. APIs de busca de vários provedores têm consistência eventual. Só considero a tentativa falha depois de uma janela razoável sem nada aparecer, e essa janela depende do provedor.
- O webhook pode chegar antes do job. Se o provedor criou a cobrança, provavelmente vai mandar o evento de criação ou confirmação. O handler do webhook precisa saber casar o evento com a tentativa pela referência e resolver o
unknown. O job, ao rodar, vê que o status já mudou e sai. resolveAsusa update condicional. Algo comowhere('status', 'unknown')->update(...)e checar as linhas afetadas, para que job e webhook não briguem pelo mesmo registro.- Esgotou os retries, alerta humano. Uma tentativa que segue
unknowndepois de horas vai para uma fila de revisão, não parafailedautomático.
E a última rede de segurança é a conciliação diária com o extrato do provedor. Se algo escapou de tudo isso, uma cobrança no extrato sem tentativa succeeded correspondente aparece como divergência no dia seguinte.
O que fazer quando o provedor não aceita Idempotency-Key?
Nem todo gateway tem idempotência nativa. Nesse caso, a responsabilidade é inteira sua:
- Envie sempre uma referência externa única por tentativa, no campo que o provedor oferecer para isso.
- Antes de qualquer retry, consulte por essa referência. Se existir, use o que existe.
- Só reenvie se a consulta confirmar que não há nada e a janela de consistência já passou.
- Trate qualquer timeout como
unknown, sem exceção.
É mais lento e mais verboso, mas é o único jeito honesto. Retry cego em provedor sem idempotência é cobrança duplicada esperando acontecer.
Vale o mesmo cuidado para Pix, transferências e estornos?
Vale mais. Cobrança duplicada no cartão gera reclamação e estorno. Transferência Pix duplicada é dinheiro saindo da conta duas vezes, e recuperar depende da boa vontade de quem recebeu. Toda operação que move dinheiro para fora (transferência, pagamento de boleto, estorno, repasse de split) passa pelo mesmo modelo: tentativa persistida, chave gerada antes, estado unknown e resolução por consulta.
No caso de saída de dinheiro, eu também debito o saldo interno no momento em que abro a tentativa, como reserva, e só desfaço a reserva quando a tentativa é resolvida como failed. Assim um unknown nunca libera saldo para uma segunda operação.
Quais são os trade-offs dessa abordagem?
Não é de graça, e vale ser claro sobre o custo:
- UX com estado intermediário. O usuário pode ver "processando" por alguns minutos em vez de um sucesso ou erro imediato. Em checkout, isso exige uma tela que faz polling ou escuta o status.
- Mais tabela e mais job. Cada operação externa ganha um registro de tentativa e um caminho de resolução. Para um CRUD comum seria excesso; para dinheiro é o mínimo.
- Timeouts mais longos. Com um timeout curto demais, você produz
unknownà toa. Eu uso connect timeout curto (para falhar rápido quando o provedor está fora) e timeout de leitura generoso. - Dependência de consulta. Se o provedor não tem como buscar por referência, a resolução automática fica limitada e sobra mais trabalho para revisão manual e conciliação.
Conclusão: o que mudar no seu código amanhã
Se você tem um try/catch em volta da criação de cobrança que devolve "erro, tente novamente" para qualquer exceção, comece por aí. Procure no projeto todos os pontos que chamam um gateway para criar ou mover dinheiro e verifique quatro coisas: a chave de idempotência é gerada antes da chamada e persistida? O retry reaproveita a mesma chave e o mesmo payload? Timeout e 5xx viram unknown em vez de failed? Existe um job que resolve unknown consultando o provedor e um índice que impede duas tentativas abertas para a mesma cobrança?
Se alguma resposta for não, o seu sistema hoje confia que a rede nunca falha no pior momento. Ela falha, e geralmente na sexta à noite, com fila cheia.