Skip to content

feat(http): retry seguro de POST + idempotência por externalId (release v3.2.0)#23

Merged
andrenfe merged 4 commits into
masterfrom
feat/safe-retry-idempotency
Jul 7, 2026
Merged

feat(http): retry seguro de POST + idempotência por externalId (release v3.2.0)#23
andrenfe merged 4 commits into
masterfrom
feat/safe-retry-idempotency

Conversation

@andrenfe

@andrenfe andrenfe commented Jul 7, 2026

Copy link
Copy Markdown
Member

Objetivo

Tornar o retry seguro por default e habilitar emissão idempotente de NFS-e, eliminando o risco de nota fiscal duplicada e o contorno de manter dois Nfe\Client (um sem retry para escrita, outro com retry para leitura).

Origem: proposta da integração WooCommerce (woo-nfe). OpenSpec change: add-safe-retry-idempotency.

O que muda

  • Retry ciente de método (Nfe\Http\RetryingTransport):

    Método 429 5xx conexão não estabelecida ambíguo/não-classificado
    GET/PUT/DELETE/HEAD/OPTIONS retenta retenta retenta retenta
    POST retenta não retenta não
    POST + header Idempotency-Key retenta retenta retenta retenta
  • Fase da falha estruturadaNfe\Http\FailurePhase (ConnectionNotEstablished/RequestMaybeSent) + ApiConnectionException->failurePhase/->curlErrno. CurlTransport classifica por errno (allowlist 5/6/7/35); Psr18Transport por NetworkExceptionInterface.

  • Override de retry por chamadaRequestOptions->retry (?RetryPolicy, 4º parâmetro). Liga/desliga o retry de uma única chamada nos dois sentidos; o decorator de retry passa a envolver sempre o transporte base.

  • Reconciliação por externalId (NFS-e) — ServiceInvoicesResource::findByExternalId() (rota dedicada GET /v1/companies/{id}/serviceinvoices/external/{externalId}, contrato de coleção do fio) + isDuplicateExternalId() estático; campo externalId no DTO ServiceInvoice (anexado ao fim do construtor — puramente aditivo).

⚠️ Mudança de comportamento (safety fix, minor)

POST deixa de retentar em 5xx (e em timeout ambíguo). Reexecutar um POST pode duplicar uma NFS-e. Rota de escape: enviar externalId e reconciliar com findByExternalId() após falha ambígua; ou RequestOptions(retry: ...) por chamada.

Ciclo de emissão idempotente

try {
    $r = $nfe->serviceInvoices->create($companyId, $data + ['externalId' => $pedidoId]);
} catch (ApiErrorException $e) {
    if (ServiceInvoicesResource::isDuplicateExternalId($e) || $e instanceof ServerException) {
        $r = $nfe->serviceInvoices->findByExternalId($companyId, $pedidoId); // null = não criada, seguro reemitir
    } else { throw $e; }
}

Validação ao vivo (2026-07-06)

Sonda contra a API real reproduziu o cenário-alvo: um create() retornou HTTP 500 e mesmo assim criou a nota (Issued) — exatamente o 5xx ambíguo que, com o retry antigo, teria duplicado a NFS-e. Também confirmados: Idempotency-Key ignorado pela API; externalId como chave única (400 "already exists"); a rota /external (contrato de coleção, miss = 200 lista vazia).

Qualidade

  • 215 testes verdes (novos: matriz de retry por método/fase, classificação cURL/PSR-18, override por chamada, reconciliação/duplicata), em PHP 8.2 e 8.4.
  • PHPStan e php-cs-fixer limpos.
  • Docs atualizadas (docs/configuration.md, cookbook NFS-e) e CHANGELOG.md ([Unreleased]).

Release v3.2.0 (corte no PR)

Seguindo o padrão do v3.1.0 (be00f2d), o corte é feito neste PR:

  • src/Version.php::CURRENT3.2.0 (fonte canônica; o CI valida tag↔Version.php).
  • CHANGELOG.md: [Unreleased] promovida para [3.2.0] — 2026-07-06.
  • VERSION (arquivo órfão legado): corrigido 3.0.0-dev3.2.0. Nada no pipeline o lê
    (a versão canônica é src/Version.php); estava defasado desde o rewrite v3. Follow-up:
    remover o arquivo ou ensinar o release.sh a bumpá-lo, para não voltar a driftar.

🏷️ Tag v3.2.0 somente após o merge (master protegida). ⚠️ Como o CHANGELOG já foi
promovido aqui, não rodar a promoção do release.sh de novo pós-merge (duplicaria a seção);
apenas criar/pushar a tag.

SemVer

Minor (v3.2.0). Tudo aditivo; a mudança de default do POST é defendida como correção de
segurança. externalId foi anexado ao fim do construtor do DTO para ser aditivo mesmo sob
a política estrita de "construtor é contrato".

Fora deste PR (rastreio)

  • Pedidos ao time da API: honrar Idempotency-Key; 503→404 para company inexistente; error code estruturado na duplicata; alinhar o YAML de /external ao fio (envelope de coleção).
  • Descobertas ortogonais em aberto (não bloqueiam este PR): DTO ServiceInvoice cobre poucos campos do fio e o codegen ignora specs com schemas inline.

🏷️ Tag v3.2.0 somente após o merge (política de master protegida).

andrenfe added 3 commits July 6, 2026 21:10
…FS-e

Torna o retry seguro por default e habilita emissão idempotente de NFS-e,
eliminando o risco de nota duplicada e o contorno de dois clients.

- Retry ciente de método (Nfe\Http\RetryingTransport): POST não retenta mais
  em 5xx nem em falha de rede ambígua (evita reemitir NFS-e); GET/PUT/DELETE/
  HEAD/OPTIONS inalterados; 429 e falha de conexão comprovada ainda retentam;
  um header Idempotency-Key restaura o retry pleno (a API ainda não honra).
- Nfe\Http\FailurePhase (ConnectionNotEstablished/RequestMaybeSent) +
  ApiConnectionException->failurePhase/curlErrno; CurlTransport classifica por
  errno (allowlist 5/6/7/35), Psr18Transport por NetworkExceptionInterface.
- RequestOptions->retry: override de retry por chamada (liga/desliga em uma
  única chamada); o decorator de retry passa a envolver sempre o transporte.
- ServiceInvoicesResource::findByExternalId() (rota dedicada /external, contrato
  de coleção do fio) + isDuplicateExternalId() estático; campo externalId no DTO
  ServiceInvoice (anexado ao fim do construtor — aditivo).

Mudança de comportamento (safety fix, minor): POST deixa de retentar em 5xx.
Rota de escape: enviar externalId e reconciliar via findByExternalId após falha
ambígua; ou RequestOptions(retry: ...) por chamada.

Validado ao vivo (2026-07-06): create() pode retornar HTTP 500 e ainda assim
criar a nota — o 5xx ambíguo que esta mudança impede de duplicar.

Ref: OpenSpec change add-safe-retry-idempotency.
O arquivo VERSION ficou preso em 3.0.0-dev desde o rewrite v3 — permaneceu
3.0.0-dev inclusive nas tags v3.0.0 e v3.1.0. É um órfão legado da era v2.x:
nada no pipeline o lê (a fonte canônica de versão é src/Version.php::CURRENT,
que release.sh e .github/workflows/release.yml bumpam e validam).

Corrige o valor para a linha em desenvolvimento nesta branch. Follow-up sugerido:
remover o arquivo (fonte única = src/Version.php) OU ensinar o release.sh a
bumpá-lo, para não voltar a driftar.
Segue o padrão do release v3.1.0 (be00f2d): o corte é feito no próprio PR.

- src/Version.php::CURRENT 3.1.0 → 3.2.0 (fonte canônica; o CI valida tag↔Version.php).
- CHANGELOG: promove [Unreleased] → [3.2.0] — 2026-07-06, mantendo [Unreleased] vazia no topo.
- VERSION 3.2.0-dev → 3.2.0 (alinha o arquivo órfão à versão cortada; ver commit anterior).

Tag v3.2.0 fica para APÓS o merge (master protegida). Não rodar a promoção do
release.sh de novo pós-merge — o CHANGELOG já está cortado; apenas taggear.
andrenfe added a commit that referenced this pull request Jul 7, 2026
Desde 45aa6e8 ("master passa a ser a linha canônica v3+"), o ci.yml continuou
disparando apenas em push/PR da branch `v3`. Efeito: nenhum PR contra master
rodou o CI funcional (matriz PHP 8.2/8.3/8.4 + PHPStan + CS + generate:check) —
só o CodeQL. Confirmado nos PRs #21 (release v3.1.0) e #23.

Adiciona `master` aos gatilhos push e pull_request (mantém `v3` por segurança;
a branch ainda existe, congelada em v3.0.0). Mudança não-destrutiva, só de
gatilho — sem uso de input não confiável.
@andrenfe andrenfe self-assigned this Jul 7, 2026
@andrenfe andrenfe merged commit d230dbb into master Jul 7, 2026
9 checks passed
@andrenfe andrenfe deleted the feat/safe-retry-idempotency branch July 7, 2026 00:35
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.

1 participant