Skip to content

PHPAY-174: feat: fall back only when nothing provably happened - #183

Merged
mariolucasdev merged 2 commits into
developfrom
feat/phpay-174
Oct 28, 2026
Merged

mariolucasdev merged 2 commits into
developfrom
feat/phpay-174

Conversation

@mariolucasdev

Copy link
Copy Markdown
Collaborator

Closes #174

A regra

A pergunta nunca é "falhou?". É "ficou provado que não aconteceu?" — porque
entregar a mesma cobrança a outro gateway depois de uma falha ambígua cria uma
segunda cobrança, e aqui é pior que um retry repetido: retry o gateway
dedupe por chave de idempotência, e entre dois PSPs não existe chave de
idempotência
.

situação cai? por quê
DNS, conexão, TLS ✅ provadamente não saiu
timeout antes de conectar ✅ idem
401 / 403 ✅ recusou antes de processar
429 ✅ idem
regra do gateway, sem HTTP ✅ nada saiu, e a regra do próximo é outra
4xx de payload ❌ erraria igual no próximo
5xx já na linha ❌ pode ter criado a cobrança
timeout com conexão aberta ❌ idem

Cada linha tem teste.

Uma cópia só da classificação

A issue pedia para não reimplementar o neverSent(). Ele saiu do
RetryPolicy para PHPay\Http\Delivery, e as duas políticas consultam de lá.
O parágrafo do docblock que explicava as duas majors do Guzzle foi junto, porque
quem responde a pergunta agora é o Delivery.

União de meios, interseção de capacidades

supportedMethods() devolve a união — e isso não é o erro que declarar
a união de capacidades seria:

  • capacidade é perguntada uma vez, contra este objeto. O decorator
    declara só cobrança e cobrança portável, nunca clientes, webhooks ou estornos
    dos gateways de dentro;
  • meio é escolhido por cobrança: createCharge() filtra a lista antes
    de tentar qualquer coisa.

Então uma cadeia de Asaas e Rede realmente faz Pix e crédito — só nunca no
mesmo gateway. Sem essa filtragem, uma cobrança Pix chegaria a quem não faz
Pix, depois de já ter desistido de quem fazia. Era a armadilha que a issue
descrevia.

O que os testes ensinaram sobre as camadas

Os primeiros testes de erro de transporte falharam, e o motivo é bom: o retry
roda dentro do gateway e o fallback fora dele
. Um erro que provadamente não
saiu é repetido três vezes no mesmo gateway antes de o próximo entrar — então a
fila precisa de três respostas, não uma. Virou teste nomeado.

Uma cadeia pode ser inútil sem avisar

Montei os testes com Asaas + Woovi e eles não funcionavam: o Asaas exige
vencimento no Pix e a Woovi o recusa
, então aquela dupla nunca tem as duas
como candidatas para a mesma cobrança. O fallback funciona — sempre cai —, mas
a lista não dá a redundância que aparenta. Está documentado, porque é o tipo de
coisa que só se descobre em produção.

Um defeito da fase 3 que este trabalho encontrou

Ao procurar uma cadeia que servisse, descobri que a Cielo engolia o
dueDate em silêncio
: o corpo saía sem nenhum traço da data. Corrigi aqui
porque é código que eu mesmo escrevi há pouco, e porque silêncio em campo
monetário é exatamente o que este projeto recusa.

  • CieloSaleRequest::fromIntent() agora recusa dueDate;
  • Method::BOLETO saiu do supportedMethods() da Cielo, porque
    ChargeIntent::boleto() exige vencimento e o nome do campo na API dela eu
    não confirmei — procurei na documentação e numa busca. Chutar
    ExpirationDate seria o que o projeto recusa com o envelope da Rede.
  • fica em Cielo: confirmar o campo de vencimento do boleto, para devolvê-lo ao caminho portável #182, que é confirmação de sandbox, não trabalho de desenho.

É redução do que a fase 3 entregou, e é correção: engolir a data é pior que não
suportar.

Observabilidade

new FallbackGateway($gateways, function (string $gateway, ?Throwable $falha) {
    logger()->warning('phpay', ['gateway' => $gateway, 'falhou' => $falha?->getMessage()]);
});

Sem isso, um primário fora do ar fica invisível.

Fora de escopo, e dito na documentação

  • leitura e estorno não caem: a cobrança só existe onde foi criada;
  • charge() é do primário e não tem fallback — devolve recurso com estado,
    e qual gateway atende só se decide na chamada terminal, dentro de um objeto
    que o decorator já não segura. Recusá-lo seria declarar capacidade e lançar
    de dentro;
  • circuit breaker fica fora: precisa de estado entre requisições, e o
    núcleo é sem estado de propósito;
  • no Asaas, createCharge() cria o cliente antes da cobrança, então cair deixa
    um cliente órfão lá. Registro sem dinheiro, bem menos grave que cobrança
    dupla, e está documentado.

Portão

  • 863 testes (+18), 2348 asserções
  • PHPStan nível 9 limpo
  • smoke: 105 verificações
  • docs/fallback.md novo, ligado no índice

FallbackGateway tries a list of gateways in order, and the question it
asks is never 'did it fail?' - it is 'did it provably not happen?'.
Handing the same charge to another gateway after an ambiguous failure
creates a second charge, and that is worse than a repeated retry: a
retry can be deduplicated by an idempotency key, and two different PSPs
share nothing that could deduplicate them.

The answer comes from Http\Delivery, extracted from RetryPolicy so the
two policies consult one copy. A second copy would drift, and the drift
would show up as somebody paying twice.

supportedMethods() returns the union, not the intersection, and that is
not the mistake declaring the union of capabilities would be: a
capability is asked once against this object, while a method is chosen
per charge, and createCharge() filters the list before trying anything.
Without that filter a Pix charge would reach a gateway that cannot do
Pix, after the one that could had already been given up on.

Testing this found the Cielo dropping a due date in silence on the
portable path. It now refuses it, and the billet left its
supportedMethods() until the Cielo's own field name is confirmed -
guessing it is what this project refuses to do with the Rede webhook
envelope.
A timeout with the connection already open is a ConnectException in
Guzzle 7 and a NetworkTimeoutException in Guzzle 8, so the same test
cannot hold in both: on 8 the class alone already means nothing was
sent, and the fallback is right to take it.

Each case now skips on the major where it does not exist, with the
reason, the way RetryPolicyTest already does - and the Guzzle 8 side
gets its own test instead of a gap.

Verified by installing Guzzle 8 locally and running the whole suite and
PHPStan against it, not by reasoning about it.
@mariolucasdev

Copy link
Copy Markdown
Collaborator Author

Correção do CI: dois cenários que pertencem a uma major só

Os jobs Guzzle 8 e PHP 8.4 - highest reprovaram num teste só — NÃO cai num timeout com a conexão já aberta.

O motivo é o que o CLAUDE.md já avisa: timeout com conexão aberta é ConnectException no Guzzle 7 e NetworkTimeoutException no 8. Na 8, a ConnectException por si já significa que nada saiu — então cair é a decisão certa lá, e o teste que exigia o contrário estava errado sobre aquela major, não sobre o código.

Cada caso agora leva ->skip() na major onde não existe, com o motivo, como o RetryPolicyTest já fazia. E o lado do Guzzle 8 ganhou teste próprio em vez de ficar sem cobertura.

Verifiquei instalando a Guzzle 8 de verdade, não raciocinando sobre ela:

Guzzle 7.16 Guzzle 8.3.1
suíte 863 passam, 2 pulados 861 passam, 4 pulados
PHPStan nível 9 limpo limpo

Depois restaurei a 7 e conferi que o composer.lock não mudou.

@mariolucasdev
mariolucasdev merged commit d09a826 into develop Oct 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FallbackGateway: cair para o próximo gateway só quando ficou provado que nada saiu

1 participant