Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,43 @@ e este projeto segue [Versionamento Semântico](https://semver.org/lang/pt-BR/sp

## [Unreleased]

## [3.2.0] — 2026-07-06

### ⚠️ Mudança de comportamento — retry de `POST`

- O transporte **não retenta mais requisições `POST` em respostas HTTP 5xx** nem
em falhas de rede ambíguas (timeout após o envio). Reexecutar um `POST` pode
duplicar o efeito — na emissão de NFS-e, isso significa **nota fiscal
duplicada**. Sonda ao vivo (2026-07-06) reproduziu o risco: um `create()`
retornou `HTTP 500` e ainda assim emitiu a nota. `POST` só é retentado agora
em `429`, em falha comprovada de conexão (a requisição nunca chegou ao
servidor) ou quando carrega um header `Idempotency-Key`. Métodos idempotentes
(`GET`/`PUT`/`DELETE`/`HEAD`/`OPTIONS`) seguem retentando como antes.
- **Rotas de escape**: para emissão retry-safe, envie um `externalId` e reconcilie
com `findByExternalId()` após uma falha ambígua (veja *Adicionado*); para
restaurar o retry por chamada, passe `RequestOptions(retry: ...)`.

### Adicionado

- Retry ciente de método/idempotência em `Nfe\Http\RetryingTransport` (tabela de
decisão por método × status × fase da falha × header `Idempotency-Key`)
(`add-safe-retry-idempotency`).
- `Nfe\Http\FailurePhase` (`ConnectionNotEstablished` / `RequestMaybeSent`) e os
campos `failurePhase`/`curlErrno` em `Nfe\Exception\ApiConnectionException`:
`CurlTransport` classifica por allowlist de errnos (5/6/7/35 → conexão não
estabelecida) e `Psr18Transport` por `NetworkExceptionInterface`.
- Override de retry por requisição: `Nfe\Http\RequestOptions` ganha o campo
`retry` (`?RetryPolicy`), permitindo ligar/desligar o retry de uma única
chamada sem manter dois clients. O decorator de retry passa a envolver sempre
o transporte base.
- `ServiceInvoicesResource::findByExternalId($companyId, $externalId)` — recupera
a NFS-e pela chave do integrador via rota dedicada
`GET /v1/companies/{id}/serviceinvoices/external/{externalId}` (hit = envelope
de coleção; miss = `null`; confirmado ao vivo em 2026-07-06).
- `ServiceInvoicesResource::isDuplicateExternalId(ApiErrorException $e): bool` —
reconhece a rejeição `400 "service invoice with external id (…) already exists"`.
- Campo `externalId` no DTO `Nfe\Resource\Dto\ServiceInvoices\ServiceInvoice`.

## [3.1.0] — 2026-07-03

### Adicionado
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.0-dev
3.2.0
53 changes: 44 additions & 9 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,9 @@ $nfe = new Client(config: new Config(

## Retry (`Nfe\Http\RetryPolicy`)

O transporte retenta automaticamente respostas **HTTP 429** e **5xx** com
backoff exponencial e jitter. O padrão é `maxRetries: 3`, `baseDelay: 1.0`s,
`maxDelay: 30.0`s, `jitter: 0.3` (±30%). Erros 4xx de negócio **não** são
retentados.
O transporte retenta automaticamente falhas transitórias com backoff exponencial
e jitter. O padrão é `maxRetries: 3`, `baseDelay: 1.0`s, `maxDelay: 30.0`s,
`jitter: 0.3` (±30%). Erros 4xx de negócio (exceto 429) **não** são retentados.

```php
use Nfe\Http\RetryPolicy;
Expand All @@ -93,6 +92,30 @@ use Nfe\Http\RetryPolicy;
$config = new Nfe\Config(apiKey: $key, retry: RetryPolicy::none());
```

### O retry é ciente do método HTTP

Reexecutar um **POST** pode duplicar um efeito (emitir a mesma NFS-e duas vezes),
então a decisão de retentar depende do método e da fase da falha, não só do
status:

| Método | 429 | 5xx | Falha de conexão (não chegou ao servidor) | Falha ambígua (pode ter chegado) |
| --- | --- | --- | --- | --- |
| `GET`/`PUT`/`DELETE`/`HEAD`/`OPTIONS` | retenta | retenta | retenta | retenta |
| `POST` | retenta | **não** | retenta | **não** |
| `POST` com header `Idempotency-Key` | retenta | retenta | retenta | retenta |

A fase da falha vem classificada em `Nfe\Http\FailurePhase` (`ConnectionNotEstablished`
vs `RequestMaybeSent`), exposta em `ApiConnectionException::$failurePhase`. A
classificação é conservadora: na dúvida, assume-se que a requisição pode ter sido
enviada (não retenta POST).

:::warning Mudança de comportamento na v3.2.0
Até a v3.1.x, um `POST` era retentado em 5xx como qualquer outra requisição.
A partir da v3.2.0 isso **não** acontece mais — é uma correção de segurança
(evita nota fiscal duplicada). Para emissão retry-safe, veja o padrão com
`externalId` em [Notas de serviço](./recursos/service-invoices.md#emissão-idempotente-retry-seguro).
:::

## Modelo de duas chaves

A plataforma separa a cobrança entre a API principal (emissão — cobrada por
Expand Down Expand Up @@ -126,29 +149,41 @@ dados — informe uma `dataApiKey` provisionada. Veja
## Overrides por requisição (`Nfe\Http\RequestOptions`)

Todo método de recurso aceita um `RequestOptions` opcional como último argumento
para sobrescrever chave, host ou timeout **de uma única chamada** — útil em
integrações multi-tenant:
para sobrescrever chave, host, timeout ou **política de retry** de uma única
chamada — útil em integrações multi-tenant e para desligar o retry numa emissão
específica sem manter dois clients:

```php
use Nfe\Http\RequestOptions;
use Nfe\Http\RetryPolicy;

$invoice = $nfe->serviceInvoices->retrieve(
$companyId,
$invoiceId,
new RequestOptions(apiKey: $chaveDoTenant, timeout: 120),
);

// Desligar retry só nesta emissão (o resto do client mantém o padrão):
$nota = $nfe->serviceInvoices->create(
$companyId,
$data,
new RequestOptions(retry: RetryPolicy::none()),
);
```

| Campo | Efeito |
| --- | --- |
| `apiKey` | Substitui a chave resolvida para esta chamada. |
| `baseUrl` | Aponta a chamada para outro host (mock, proxy, ambiente próprio). |
| `timeout` | Timeout específico desta chamada, em segundos. |
| `retry` | Política de retry só desta chamada (sobrepõe a do client, nos dois sentidos). |

:::note Sem `idempotencyKey`
A API da NFE.io não honra o header `Idempotency-Key` atualmente, então o
`RequestOptions` não expõe esse campo. Quando a API adicionar suporte, ele
entrará em uma release menor aditiva.
A API da NFE.io não honra o header `Idempotency-Key` atualmente (reconfirmado
por sonda ao vivo em 2026-07-05), então o `RequestOptions` não expõe esse campo.
Enquanto isso, a emissão retry-safe se faz com `externalId` — veja
[Notas de serviço](./recursos/service-invoices.md#emissão-idempotente-retry-seguro).
Quando a API honrar o header, ele entrará em uma release menor aditiva.
:::

## Sandbox vs. Produção
Expand Down
59 changes: 59 additions & 0 deletions docs/recursos/service-invoices.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ recurso de nota que usa o host `api.nfe.io`; os demais usam `api.nfse.io`.
| `create($companyId, $data)` | Emite a NFS-e. | `ServiceInvoicePending\|ServiceInvoiceIssued` |
| `list($companyId, $options = [])` | Lista paginada (page-style, `pageIndex` 1-based) com filtros de data. | `ListResponse` |
| `retrieve($companyId, $invoiceId)` | Consulta uma NFS-e por id. | `ServiceInvoice` |
| `findByExternalId($companyId, $externalId)` | Recupera a NFS-e pelo `externalId` do integrador (rota dedicada). | `?ServiceInvoice` |
| `isDuplicateExternalId($e)` *(estático)* | Reconhece a rejeição `400 "already exists"` de `externalId` duplicado. | `bool` |
| `cancel($companyId, $invoiceId)` | Cancela (síncrono) e devolve o modelo atualizado. | `ServiceInvoice` |
| `sendEmail($companyId, $invoiceId)` | Reenvia a nota por e-mail ao tomador. | `array` (típico: `{sent, message}`) |
| `downloadPdf($companyId, $invoiceId)` | PDF da nota. | `string` (bytes crus) |
Expand Down Expand Up @@ -98,6 +100,63 @@ if ($flow === 'Issued') {
status concreto. Veja [Emissão assíncrona e polling](../async-and-polling.md).
:::

## Emissão idempotente (retry seguro)

Emitir NFS-e é um `POST` que cria efeito fiscal. Se a rede falhar de forma
ambígua (5xx ou timeout após o envio), você não sabe se a nota foi criada — e
**reexecutar cegamente pode duplicá-la**. Sonda ao vivo (2026-07-06) confirmou o
caso: um `create()` retornou `HTTP 500` e mesmo assim emitiu a nota.

Por isso o SDK **não** retenta `POST` em 5xx (veja
[a tabela de retry por método](../configuration.md#o-retry-é-ciente-do-método-http)).
Para emissão retry-safe, envie um `externalId` — a API o trata como **chave
única** (uma segunda emissão com o mesmo valor é recusada com
`400 "service invoice with external id (…) already exists"`) — e feche o ciclo:

```php
use Nfe\Exception\ApiErrorException;
use Nfe\Exception\ServerException;
use Nfe\Resource\ServiceInvoicesResource;

$companyId = '55df4dc6b6cd9007e4f13ee8';
$externalId = $pedidoId; // estável entre tentativas (ex.: id do pedido)

try {
$result = $nfe->serviceInvoices->create($companyId, [
'externalId' => $externalId,
'cityServiceCode' => '2690',
'description' => 'Manutenção e suporte técnico',
'servicesAmount' => 100.0,
'borrower' => ['federalTaxNumber' => 191, 'name' => 'Banco do Brasil SA'],
]);
} catch (ApiErrorException $e) {
// Duplicata (retry já processado) OU 5xx ambíguo (pode ter criado): reconcilie.
if (ServiceInvoicesResource::isDuplicateExternalId($e) || $e instanceof ServerException) {
$result = $nfe->serviceInvoices->findByExternalId($companyId, $externalId);
// Pode ser null se a nota realmente não foi criada — aí é seguro reemitir.
} else {
throw $e;
}
}
```

`findByExternalId()` usa a rota dedicada `GET …/serviceinvoices/external/{externalId}`
e devolve `?ServiceInvoice` (`null` quando nenhuma nota carrega esse id).

:::note Lag de indexação
Logo após um `202` (pending), a nota pode levar alguns segundos para aparecer na
rota de busca. Se `findByExternalId()` retornar `null` imediatamente após uma
falha ambígua, reconsulte com um pequeno backoff antes de concluir que a nota não
existe. A rejeição `400 "already exists"` (via `isDuplicateExternalId()`), por
outro lado, é imediata.
:::

:::tip Um único client
Com a emissão retry-safe acima, não é mais necessário manter dois `Nfe\Client`
(um sem retry para escrita, outro com retry para leitura): deixe o retry ligado
para tudo e use `externalId` na emissão.
:::

## Baixar PDF e XML (bytes crus)

Os downloads retornam uma `string` com os bytes do arquivo — grave com
Expand Down
11 changes: 8 additions & 3 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ public function send(Request $request, ?RequestOptions $options = null): Respons
query: $request->query,
body: $request->body,
timeout: $request->timeout > 0 ? $request->timeout : $this->config->timeout,
retry: $request->retry,
);

return $this->transport->send($authoritative);
Expand Down Expand Up @@ -191,16 +192,20 @@ public function request(
headers: [],
query: $query,
body: $body !== null ? json_encode($body, JSON_THROW_ON_ERROR) : null,
retry: $options->retry ?? null,
);
return $this->send($request, $options);
}

private function buildTransport(Config $config): Transport
{
$base = $config->transport ?? new CurlTransport(defaultTimeout: $config->timeout);
if ($config->retry->maxRetries <= 0) {
return $base;
}

// The retry decorator always wraps the base transport, even when the
// client-level policy is `none()`: a per-request RequestOptions may
// *enable* retries on an otherwise zero-retry client, and vice versa.
// With maxRetries=0 and no override, the decorator makes exactly one
// attempt (cheap pass-through).
return new RetryingTransport($base, $config->retry);
}

Expand Down
28 changes: 27 additions & 1 deletion src/Exception/ApiConnectionException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,36 @@

namespace Nfe\Exception;

use Nfe\Http\FailurePhase;
use Throwable;

/**
* Raised for network-level failures: DNS resolution, TCP connect, TLS,
* or read timeouts before any HTTP response is received.
*
* No status code is available for these failures (statusCode = 0).
*
* Carries structured classification of the failure phase ({@see FailurePhase})
* so the retry layer can decide whether a non-idempotent method (POST) is safe
* to reexecute. When the transport cannot classify the failure, `$failurePhase`
* is null and the retry layer treats it conservatively (as if the request may
* have been sent).
*/
final class ApiConnectionException extends ApiErrorException {}
final class ApiConnectionException extends ApiErrorException
{
/**
* @param array<string, string> $responseHeaders
*/
public function __construct(
string $message,
int $statusCode = 0,
?string $responseBody = null,
array $responseHeaders = [],
?string $errorCode = null,
?Throwable $previous = null,
public readonly ?FailurePhase $failurePhase = null,
public readonly ?int $curlErrno = null,
) {
parent::__construct($message, $statusCode, $responseBody, $responseHeaders, $errorCode, $previous);
}
}
31 changes: 29 additions & 2 deletions src/Http/CurlTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@
*/
final class CurlTransport implements Transport
{
/**
* cURL error codes that prove the request never reached the server
* (DNS/proxy resolution, TCP connect, TLS handshake). Any other errno —
* notably CURLE_OPERATION_TIMEDOUT (28), which does not distinguish a
* connect-timeout from a read-timeout — is treated as ambiguous.
*
* @var list<int>
*/
private const CONNECTION_NOT_ESTABLISHED_ERRNOS = [
5, // CURLE_COULDNT_RESOLVE_PROXY
6, // CURLE_COULDNT_RESOLVE_HOST
7, // CURLE_COULDNT_CONNECT
35, // CURLE_SSL_CONNECT_ERROR
];

/**
* @param int $defaultTimeout Used when {@see Request::$timeout} is 0 (default).
*/
Expand All @@ -27,7 +42,10 @@ public function send(Request $request): Response
{
$curl = curl_init();
if ($curl === false) {
throw new ApiConnectionException('Failed to initialise cURL handle.');
throw new ApiConnectionException(
'Failed to initialise cURL handle.',
failurePhase: FailurePhase::ConnectionNotEstablished,
);
}

$headers = $this->serializeHeaders($request->headers);
Expand All @@ -37,7 +55,10 @@ public function send(Request $request): Response
$url = $request->url();
$method = strtoupper($request->method);
if ($url === '' || $method === '') {
throw new ApiConnectionException('Cannot send request with empty URL or method.');
throw new ApiConnectionException(
'Cannot send request with empty URL or method.',
failurePhase: FailurePhase::ConnectionNotEstablished,
);
}
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
Expand Down Expand Up @@ -66,9 +87,15 @@ public function send(Request $request): Response
$msg = curl_error($curl);
curl_close($curl);

$phase = in_array($errno, self::CONNECTION_NOT_ESTABLISHED_ERRNOS, true)
? FailurePhase::ConnectionNotEstablished
: FailurePhase::RequestMaybeSent;

throw new ApiConnectionException(
"Network error talking to NFE.io ({$errno}): {$msg}",
previous: null,
failurePhase: $phase,
curlErrno: $errno,
);
}

Expand Down
34 changes: 34 additions & 0 deletions src/Http/FailurePhase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Nfe\Http;

/**
* Fase em que uma falha de rede ocorreu, do ponto de vista da segurança de retry.
*
* Distinguir a fase é o que torna o retry de POST seguro: só é seguro reexecutar
* um POST quando temos certeza de que a requisição **não** chegou ao servidor —
* caso contrário, o servidor pode já tê-la processado (ex.: emitido uma NFS-e) e
* o retry duplicaria o efeito.
*
* A classificação é deliberadamente **conservadora**: na dúvida, assume-se
* {@see self::RequestMaybeSent}. Erramos para o lado de perder uma retentativa
* segura, nunca para o lado de reexecutar algo que pode ter sido processado.
*/
enum FailurePhase
{
/**
* A requisição comprovadamente não chegou ao servidor (DNS não resolveu,
* TCP não conectou, TLS falhou no handshake). Seguro reexecutar qualquer
* método, inclusive POST.
*/
case ConnectionNotEstablished;

/**
* A requisição pode ter chegado e sido processada pelo servidor antes da
* falha (ex.: timeout de leitura após o corpo ter sido enviado). Inseguro
* reexecutar métodos não idempotentes como POST.
*/
case RequestMaybeSent;
}
Loading