diff --git a/CHANGELOG.md b/CHANGELOG.md index 990ec1c..4571921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 2468aa9..944880f 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0-dev +3.2.0 diff --git a/docs/configuration.md b/docs/configuration.md index f4f81d5..63994c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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; @@ -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 @@ -126,17 +149,26 @@ 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 | @@ -144,11 +176,14 @@ $invoice = $nfe->serviceInvoices->retrieve( | `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 diff --git a/docs/recursos/service-invoices.md b/docs/recursos/service-invoices.md index c7adb27..92bc7ad 100644 --- a/docs/recursos/service-invoices.md +++ b/docs/recursos/service-invoices.md @@ -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) | @@ -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 diff --git a/src/Client.php b/src/Client.php index 75979ae..ee1e473 100644 --- a/src/Client.php +++ b/src/Client.php @@ -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); @@ -191,6 +192,7 @@ 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); } @@ -198,9 +200,12 @@ public function request( 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); } diff --git a/src/Exception/ApiConnectionException.php b/src/Exception/ApiConnectionException.php index 71ac883..624c2fc 100644 --- a/src/Exception/ApiConnectionException.php +++ b/src/Exception/ApiConnectionException.php @@ -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 $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); + } +} diff --git a/src/Http/CurlTransport.php b/src/Http/CurlTransport.php index 3cf0a6f..49abad6 100644 --- a/src/Http/CurlTransport.php +++ b/src/Http/CurlTransport.php @@ -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 + */ + 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). */ @@ -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); @@ -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); @@ -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, ); } diff --git a/src/Http/FailurePhase.php b/src/Http/FailurePhase.php new file mode 100644 index 0000000..dbe83a3 --- /dev/null +++ b/src/Http/FailurePhase.php @@ -0,0 +1,34 @@ +client->sendRequest($psrRequest); } catch (ClientExceptionInterface $e) { + // NetworkExceptionInterface means the request could not be delivered + // (PSR-18 contract) → safe to retry even a POST. Any other client + // exception is ambiguous: the request may already have been sent. + $phase = $e instanceof NetworkExceptionInterface + ? FailurePhase::ConnectionNotEstablished + : FailurePhase::RequestMaybeSent; + throw new ApiConnectionException( "PSR-18 transport failed: {$e->getMessage()}", previous: $e, + failurePhase: $phase, ); } diff --git a/src/Http/Request.php b/src/Http/Request.php index 5856506..399a0b5 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -23,6 +23,10 @@ * @param array> $query Query parameters. Arrays are sent as repeated keys. * @param string|null $body Pre-encoded request body. JSON-encoding is the caller's responsibility. * @param int $timeout Per-request timeout in seconds. 0 = use transport default. + * @param RetryPolicy|null $retry Per-request retry override. When set, {@see RetryingTransport} + * uses it instead of the client-level policy for this call. Travels + * on the immutable request so it applies consistently across every + * retry attempt. Null = use the client-level policy. */ public function __construct( public string $method, @@ -32,6 +36,7 @@ public function __construct( public array $query = [], public ?string $body = null, public int $timeout = 0, + public ?RetryPolicy $retry = null, ) {} /** diff --git a/src/Http/RequestOptions.php b/src/Http/RequestOptions.php index a0dad10..4aed4ab 100644 --- a/src/Http/RequestOptions.php +++ b/src/Http/RequestOptions.php @@ -8,13 +8,18 @@ * Per-request overrides applied on top of the SDK's global Config. * * Resource methods accept an optional RequestOptions to let callers override - * the API key, base URL, or timeout for a single call without rebuilding - * the Client. This is useful for multi-tenant integrations and for ad-hoc - * calls against the sandbox from production code. + * the API key, base URL, timeout, or retry policy for a single call without + * rebuilding the Client. This is useful for multi-tenant integrations, ad-hoc + * calls against the sandbox from production code, and disabling retries on a + * specific write while keeping them on for the rest of the client. * * Note on Idempotency-Key: the NFE.io API does not honor an Idempotency-Key - * header today (confirmed 2026-05-13). When the API adds support, an additive - * minor release will add an `idempotencyKey` field here. + * header today (reconfirmed via live probe on 2026-07-05). When the API adds + * support, an additive minor release will add an `idempotencyKey` field here; + * until then, attaching the header only unlocks retry-on-5xx for the POST (see + * {@see RetryingTransport}) — it does not make the server dedupe. For safe NFS-e + * emission today, send an `externalId` and reconcile with + * `ServiceInvoicesResource::findByExternalId()` after an ambiguous failure. */ final readonly class RequestOptions { @@ -22,5 +27,6 @@ public function __construct( public ?string $apiKey = null, public ?string $baseUrl = null, public ?int $timeout = null, + public ?RetryPolicy $retry = null, ) {} } diff --git a/src/Http/RetryPolicy.php b/src/Http/RetryPolicy.php index c211747..321711a 100644 --- a/src/Http/RetryPolicy.php +++ b/src/Http/RetryPolicy.php @@ -7,11 +7,13 @@ /** * Configuration for retry behavior on transient failures. * - * The policy is applied by {@see RetryingTransport} as a decorator over any - * other {@see Transport}. Retries trigger on: - * - HTTP 429 (Too Many Requests) - * - HTTP 5xx (Server errors) - * - Network-level failures wrapped in ApiConnectionException + * The policy is a value object; {@see RetryingTransport} decides *whether* a + * given failure is retried, based on the HTTP method and failure phase (see its + * decision table). In short: idempotent methods retry on 429, 5xx, and any + * network failure; POST retries only on 429 or a `ConnectionNotEstablished` + * network failure — never on 5xx or an ambiguous failure — unless it carries an + * `Idempotency-Key` header. This object only governs *how many* times and *how + * long* to wait between attempts. * * Delays follow exponential backoff with bounded jitter to avoid thundering herd: * diff --git a/src/Http/RetryingTransport.php b/src/Http/RetryingTransport.php index 4b3f96b..5b946e9 100644 --- a/src/Http/RetryingTransport.php +++ b/src/Http/RetryingTransport.php @@ -8,18 +8,36 @@ use Nfe\Exception\ApiConnectionException; /** - * Decorator that wraps any {@see Transport} with retry semantics. + * Decorator that wraps any {@see Transport} with method-aware retry semantics. * - * Retries on: - * - HTTP 429 (Too Many Requests) - * - HTTP 5xx (Server errors) - * - Network-level failures (ApiConnectionException from the inner transport) + * Retry is safe for idempotent methods (GET, PUT, DELETE, HEAD, OPTIONS) but not + * for POST, which may create a resource (e.g. issue an NFS-e). The decision is + * therefore made per method, status code, and — for network failures — the + * failure phase reported by the transport: + * + * | Method | 429 | 5xx | ConnectionNotEstablished | RequestMaybeSent | + * |-------------------------------|-------|-------|--------------------------|------------------| + * | GET/PUT/DELETE/HEAD/OPTIONS | retry | retry | retry | retry | + * | POST | retry | no | retry | no | + * | POST + `Idempotency-Key` | retry | retry | retry | retry | + * + * A POST carrying an `Idempotency-Key` header is treated as idempotent (the + * server is expected to dedupe) — this is the forward-compatible path for when + * the API honors the header. A network failure with no classified phase is + * treated conservatively as `RequestMaybeSent`. * * Honors the `Retry-After` response header when present (integer seconds only; * HTTP-date form is not supported in v3.0). */ final class RetryingTransport implements Transport { + /** + * HTTP methods safe to retry unconditionally (idempotent per RFC 9110). + * + * @var list + */ + private const IDEMPOTENT_METHODS = ['GET', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']; + public function __construct( private readonly Transport $inner, private readonly RetryPolicy $policy, @@ -28,27 +46,25 @@ public function __construct( public function send(Request $request): Response { + $policy = $request->retry ?? $this->policy; + $idempotent = $this->isIdempotent($request); $attempt = 0; - $lastResponse = null; - $lastException = null; while (true) { try { $response = $this->inner->send($request); - if (!$this->shouldRetry($response->statusCode) || $attempt >= $this->policy->maxRetries) { + if (!$this->shouldRetryStatus($idempotent, $response->statusCode) || $attempt >= $policy->maxRetries) { return $response; } - $lastResponse = $response; - $delay = $this->resolveDelay($attempt + 1, $response); + $delay = $this->resolveDelay($attempt + 1, $response, $policy); } catch (ApiConnectionException $e) { - if ($attempt >= $this->policy->maxRetries) { + if (!$this->shouldRetryConnectionFailure($idempotent, $e) || $attempt >= $policy->maxRetries) { throw $e; } - $lastException = $e; - $delay = $this->policy->delayFor($attempt + 1); + $delay = $policy->delayFor($attempt + 1); } $this->sleep($delay); @@ -56,19 +72,63 @@ public function send(Request $request): Response } } - private function shouldRetry(int $statusCode): bool + /** + * A request is retry-safe for the full transient set when its method is + * idempotent, or when it is a POST carrying an `Idempotency-Key` header + * (server-side dedupe makes the retry safe). + */ + private function isIdempotent(Request $request): bool { - return $statusCode === 429 || ($statusCode >= 500 && $statusCode <= 599); + if (in_array(strtoupper($request->method), self::IDEMPOTENT_METHODS, true)) { + return true; + } + + return $this->hasIdempotencyKey($request); + } + + private function hasIdempotencyKey(Request $request): bool + { + foreach (array_keys($request->headers) as $name) { + if (strcasecmp($name, 'Idempotency-Key') === 0) { + return true; + } + } + + return false; + } + + private function shouldRetryStatus(bool $idempotent, int $statusCode): bool + { + if ($statusCode === 429) { + // Rate-limited requests are rejected before processing — safe to + // retry even a non-idempotent POST. + return true; + } + + // 5xx may mean the server already processed the request; only retry when + // the method is idempotent (or carries an Idempotency-Key). + return $idempotent && $statusCode >= 500 && $statusCode <= 599; + } + + private function shouldRetryConnectionFailure(bool $idempotent, ApiConnectionException $e): bool + { + if ($idempotent) { + return true; + } + + // Non-idempotent: only retry when the request provably never reached the + // server. Unclassified failures are treated as possibly-sent. + return $e->failurePhase === FailurePhase::ConnectionNotEstablished; } - private function resolveDelay(int $attempt, Response $response): float + private function resolveDelay(int $attempt, Response $response, RetryPolicy $policy): float { $retryAfter = $response->header('retry-after'); if ($retryAfter !== null && ctype_digit($retryAfter)) { - return min((float) $retryAfter, $this->policy->maxDelay); + return min((float) $retryAfter, $policy->maxDelay); } - return $this->policy->delayFor($attempt); + return $policy->delayFor($attempt); } private function sleep(float $seconds): void diff --git a/src/Resource/AbstractResource.php b/src/Resource/AbstractResource.php index c724b24..58e5ec7 100644 --- a/src/Resource/AbstractResource.php +++ b/src/Resource/AbstractResource.php @@ -336,6 +336,7 @@ private function send( apiKey: $apiKey, baseUrl: $baseUrl, timeout: $options->timeout ?? null, + retry: $options->retry ?? null, ); $encodedBody = $body !== null ? json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) : null; @@ -351,6 +352,7 @@ private function send( query: $query, body: $encodedBody === false ? null : $encodedBody, timeout: $effectiveOptions->timeout ?? 0, + retry: $effectiveOptions->retry, ); $response = $this->client->send($request, $effectiveOptions); diff --git a/src/Resource/Dto/ServiceInvoices/ServiceInvoice.php b/src/Resource/Dto/ServiceInvoices/ServiceInvoice.php index 4b7e56a..676823c 100644 --- a/src/Resource/Dto/ServiceInvoices/ServiceInvoice.php +++ b/src/Resource/Dto/ServiceInvoices/ServiceInvoice.php @@ -35,5 +35,8 @@ public function __construct( public ?float $servicesAmount = null, public ?float $totalAmount = null, public ?array $raw = null, + // Appended last so it is purely additive to the constructor signature + // (hydration is by-name, so position is irrelevant to the SDK itself). + public ?string $externalId = null, ) {} } diff --git a/src/Resource/ServiceInvoicesResource.php b/src/Resource/ServiceInvoicesResource.php index e061abd..3e36116 100644 --- a/src/Resource/ServiceInvoicesResource.php +++ b/src/Resource/ServiceInvoicesResource.php @@ -4,6 +4,8 @@ namespace Nfe\Resource; +use Nfe\Exception\ApiErrorException; +use Nfe\Exception\InvalidRequestException; use Nfe\Http\RequestOptions; use Nfe\Resource\Dto\ServiceInvoices\ServiceInvoice; use Nfe\Response\ServiceInvoiceIssued; @@ -38,7 +40,35 @@ protected function apiVersion(): string * assíncrono) ou `ServiceInvoiceIssued` quando responde HTTP 201 (emissão imediata). * Use `instanceof` para discriminar. * - * @param array $data Payload da nota (vide spec NFS-e v1). + * ## Emissão idempotente (retry seguro de POST) + * + * A API **não** honra `Idempotency-Key` (reconfirmado em 2026-07-05), então o SDK + * não retenta POST em 5xx/timeout ambíguo por default — reexecutar poderia emitir + * uma nota duplicada. Para tornar a emissão retry-safe, envie um `externalId` no + * `$data` (a API o trata como chave única) e feche o ciclo em caso de falha ambígua: + * + * ```php + * $data['externalId'] = $pedidoId; // estável entre tentativas + * try { + * $result = $nfe->serviceInvoices->create($companyId, $data); + * } catch (ApiErrorException $e) { + * // Retry (manual ou via RequestOptions) pode ser rejeitado como duplicata, + * // ou o próprio create pode ter dado 5xx APÓS criar a nota (confirmado ao vivo): + * if (ServiceInvoicesResource::isDuplicateExternalId($e) || $e instanceof ServerException) { + * // A tentativa anterior pode ter sido processada — recupere a nota já criada. + * $result = $nfe->serviceInvoices->findByExternalId($companyId, $pedidoId); + * } else { + * throw $e; + * } + * } + * ``` + * + * Nota: logo após um `202` (pending), a nota pode levar alguns segundos para indexar + * na rota de busca — se `findByExternalId()` retornar `null` de imediato, reconsulte + * com um pequeno backoff antes de concluir que a nota não existe. + * + * @param array $data Payload da nota (vide spec NFS-e v1). Inclua + * `externalId` para habilitar o ciclo de emissão idempotente acima. */ public function create( string $companyId, @@ -172,4 +202,74 @@ public function getStatus( return $this->decodeBody($response->body); } + + /** + * Recupera uma NFS-e pelo seu `externalId` (chave definida pelo integrador na emissão). + * + * Usa a rota dedicada `GET /v1/companies/{id}/serviceinvoices/external/{externalId}`. + * Contrato vivo (confirmado em 2026-07-06): no hit a API responde `200` com **envelope + * de coleção** `{"serviceInvoices":[...]}`; no miss responde `200` com lista vazia + * (não `404`). O OpenAPI modela um objeto único — tratamos ambos os formatos. + * + * Peça central do ciclo de emissão idempotente: após um retry ambíguo rejeitado como + * duplicata (vide {@see self::isDuplicateExternalId()}), recupera a nota já criada. + * + * @return ServiceInvoice|null A nota, ou `null` se nenhuma carrega esse `externalId`. + */ + public function findByExternalId( + string $companyId, + string $externalId, + ?RequestOptions $options = null, + ): ?ServiceInvoice { + $companyId = IdValidator::companyId($companyId); + if (trim($externalId) === '') { + throw new InvalidRequestException('externalId must be a non-empty string.'); + } + + $response = $this->httpGet( + "/companies/{$companyId}/serviceinvoices/external/" . rawurlencode($externalId), + options: $options, + ); + $payload = $this->decodeBody($response->body); + + // Contrato real: envelope de coleção no hit, lista vazia no miss. + if (array_key_exists('serviceInvoices', $payload)) { + $items = is_array($payload['serviceInvoices']) ? $payload['serviceInvoices'] : []; + $first = $items[0] ?? null; + + /** @var array|null $first */ + return is_array($first) ? $this->hydrate(ServiceInvoice::class, $first) : null; + } + + // Fallback defensivo: corpo com objeto único (o formato que o YAML declara), + // caso a API seja futuramente alinhada ao próprio spec. + if (isset($payload['id'])) { + return $this->hydrate(ServiceInvoice::class, $payload); + } + + return null; + } + + /** + * Reconhece a rejeição de `externalId` duplicado na emissão de NFS-e. + * + * A API recusa uma segunda emissão com o mesmo `externalId` retornando + * `400 "service invoice with external id (…) already exists"` (confirmado ao vivo + * em 2026-07-05). Como a API não devolve um error code estruturado — só texto livre — + * o casamento por mensagem vive **apenas aqui**, num único lugar testável. + * + * @param ApiErrorException $e Exceção capturada de uma chamada `create()`. + * @return bool `true` se for a rejeição de duplicata; `false` para qualquer outro 400. + */ + public static function isDuplicateExternalId(ApiErrorException $e): bool + { + if ($e->statusCode !== 400) { + return false; + } + + // A mensagem pode chegar no corpo cru ou (futuramente) no campo message. + $haystack = strtolower(($e->responseBody ?? '') . ' ' . $e->getMessage()); + + return str_contains($haystack, 'external id') && str_contains($haystack, 'already exists'); + } } diff --git a/src/Version.php b/src/Version.php index 6888c65..67035b6 100644 --- a/src/Version.php +++ b/src/Version.php @@ -12,5 +12,5 @@ */ final class Version { - public const CURRENT = '3.1.0'; + public const CURRENT = '3.2.0'; } diff --git a/tests/Http/CurlTransportFailurePhaseTest.php b/tests/Http/CurlTransportFailurePhaseTest.php new file mode 100644 index 0000000..9c8d731 --- /dev/null +++ b/tests/Http/CurlTransportFailurePhaseTest.php @@ -0,0 +1,36 @@ +send($request); + $this->fail('Expected ApiConnectionException on DNS failure.'); + } catch (ApiConnectionException $e) { + expect($e->failurePhase)->toBe(FailurePhase::ConnectionNotEstablished); + expect($e->curlErrno)->toBe(6); // CURLE_COULDNT_RESOLVE_HOST + } +}); + +it('marks a failed cURL init as ConnectionNotEstablished', function (): void { + // Empty method/URL trips the pre-send guard, which never reaches the wire. + $transport = new CurlTransport(); + $request = new Request('', 'https://api.nfe.io', ''); + + try { + $transport->send($request); + $this->fail('Expected ApiConnectionException on empty method/URL.'); + } catch (ApiConnectionException $e) { + expect($e->failurePhase)->toBe(FailurePhase::ConnectionNotEstablished); + } +}); diff --git a/tests/Http/PerRequestRetryOverrideTest.php b/tests/Http/PerRequestRetryOverrideTest.php new file mode 100644 index 0000000..e40b307 --- /dev/null +++ b/tests/Http/PerRequestRetryOverrideTest.php @@ -0,0 +1,89 @@ +push(new Response(503, [], 'fail')) + ->push(new Response(200, [], '{"id":"x"}')); + + // Client retries by default, but this call opts out. + $client = clientWith($mock, new RetryPolicy(maxRetries: 3, baseDelay: 0.001)); + + expect(fn() => $client->serviceInvoices->retrieve( + 'abc', + 'inv-1', + new RequestOptions(retry: RetryPolicy::none()), + ))->toThrow(Nfe\Exception\ServerException::class); + + // Exactly one HTTP attempt — the 503 was surfaced, not retried. + expect(count($mock->sent()))->toBe(1); +}); + +it('keeps client-level retries for other calls on the same client', function (): void { + $mock = (new MockTransport()) + ->push(new Response(503, [], 'fail')) + ->push(new Response(200, [], '{"id":"x"}')); + + $client = clientWith($mock, new RetryPolicy(maxRetries: 3, baseDelay: 0.001)); + + // No override → client policy applies → retries past the 503. + $invoice = $client->serviceInvoices->retrieve('abc', 'inv-1'); + + expect($invoice->id)->toBe('x'); + expect(count($mock->sent()))->toBe(2); +}); + +it('enables retries for a single call on a zero-retry client', function (): void { + $mock = (new MockTransport()) + ->push(new Response(503, [], 'fail')) + ->push(new Response(200, [], '{"id":"x"}')); + + // Client has retries off globally... + $client = clientWith($mock, RetryPolicy::none()); + + // ...but this call turns them on. + $invoice = $client->serviceInvoices->retrieve( + 'abc', + 'inv-1', + new RequestOptions(retry: new RetryPolicy(maxRetries: 2, baseDelay: 0.001)), + ); + + expect($invoice->id)->toBe('x'); + expect(count($mock->sent()))->toBe(2); +}); + +it('makes exactly one attempt on a zero-retry client with no override', function (): void { + $mock = (new MockTransport()) + ->push(new Response(503, [], 'fail')) + ->push(new Response(200, [], '{"id":"x"}')); + + $client = clientWith($mock, RetryPolicy::none()); + + expect(fn() => $client->serviceInvoices->retrieve('abc', 'inv-1')) + ->toThrow(Nfe\Exception\ServerException::class); + + // Regression guard: the always-on decorator must not retry when policy is none(). + expect(count($mock->sent()))->toBe(1); +}); diff --git a/tests/Http/Psr18TransportFailurePhaseTest.php b/tests/Http/Psr18TransportFailurePhaseTest.php new file mode 100644 index 0000000..4e17952 --- /dev/null +++ b/tests/Http/Psr18TransportFailurePhaseTest.php @@ -0,0 +1,54 @@ +send(new Request('GET', 'https://api.nfe.io', '/v1/x')); + test()->fail('Expected ApiConnectionException.'); + } catch (ApiConnectionException $e) { + expect($e->failurePhase)->toBe(FailurePhase::ConnectionNotEstablished); + } +}); + +it('maps a generic ClientExceptionInterface to RequestMaybeSent', function (): void { + $genericError = new class ('response parse failed') extends RuntimeException implements ClientExceptionInterface {}; + + $transport = buildPsr18($genericError); + + try { + $transport->send(new Request('POST', 'https://api.nfe.io', '/v1/x')); + test()->fail('Expected ApiConnectionException.'); + } catch (ApiConnectionException $e) { + expect($e->failurePhase)->toBe(FailurePhase::RequestMaybeSent); + } +}); diff --git a/tests/Http/RetryingTransportTest.php b/tests/Http/RetryingTransportTest.php index 366ef27..01dce11 100644 --- a/tests/Http/RetryingTransportTest.php +++ b/tests/Http/RetryingTransportTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Nfe\Exception\ApiConnectionException; +use Nfe\Http\FailurePhase; use Nfe\Http\Request; use Nfe\Http\Response; use Nfe\Http\RetryingTransport; @@ -86,3 +87,86 @@ function buildRetryingTransport(MockTransport $inner, RetryPolicy $policy): Retr expect(fn() => $rt->send(new Request('GET', 'https://api.nfe.io', '/v1/x'))) ->toThrow(ApiConnectionException::class, 'network 2'); }); + +// --- Method-aware retry (add-safe-retry-idempotency) --- + +it('does NOT retry a POST on 503', function (): void { + $mock = (new MockTransport()) + ->push(new Response(503, [], 'boom')) + ->push(new Response(200, [], 'ok')); + + $rt = buildRetryingTransport($mock, new RetryPolicy(maxRetries: 3, baseDelay: 0.001)); + + $r = $rt->send(new Request('POST', 'https://api.nfe.io', '/v1/x')); + + expect($r->statusCode)->toBe(503); + expect(count($mock->sent()))->toBe(1); +}); + +it('does retry a POST on 429 honoring Retry-After', function (): void { + $mock = (new MockTransport()) + ->push(new Response(429, ['retry-after' => '0'], 'slow down')) + ->push(new Response(201, [], 'created')); + + $rt = buildRetryingTransport($mock, new RetryPolicy(maxRetries: 2, baseDelay: 0.001)); + + $r = $rt->send(new Request('POST', 'https://api.nfe.io', '/v1/x')); + + expect($r->statusCode)->toBe(201); + expect(count($mock->sent()))->toBe(2); +}); + +it('does retry a POST when the connection was never established', function (): void { + $mock = (new MockTransport()) + ->push(new ApiConnectionException('dns', failurePhase: FailurePhase::ConnectionNotEstablished)) + ->push(new Response(201, [], 'created')); + + $rt = buildRetryingTransport($mock, new RetryPolicy(maxRetries: 2, baseDelay: 0.001)); + + $r = $rt->send(new Request('POST', 'https://api.nfe.io', '/v1/x')); + + expect($r->statusCode)->toBe(201); + expect(count($mock->sent()))->toBe(2); +}); + +it('does NOT retry a POST on an ambiguous network failure', function (): void { + $mock = (new MockTransport()) + ->push(new ApiConnectionException('read timeout', failurePhase: FailurePhase::RequestMaybeSent)) + ->push(new Response(201, [], 'created')); + + $rt = buildRetryingTransport($mock, new RetryPolicy(maxRetries: 3, baseDelay: 0.001)); + + expect(fn() => $rt->send(new Request('POST', 'https://api.nfe.io', '/v1/x'))) + ->toThrow(ApiConnectionException::class, 'read timeout'); + expect(count($mock->sent()))->toBe(1); +}); + +it('treats an unclassified POST network failure conservatively (no retry)', function (): void { + $mock = (new MockTransport()) + ->push(new ApiConnectionException('unknown')) // failurePhase = null + ->push(new Response(201, [], 'created')); + + $rt = buildRetryingTransport($mock, new RetryPolicy(maxRetries: 3, baseDelay: 0.001)); + + expect(fn() => $rt->send(new Request('POST', 'https://api.nfe.io', '/v1/x'))) + ->toThrow(ApiConnectionException::class, 'unknown'); + expect(count($mock->sent()))->toBe(1); +}); + +it('retries a POST carrying an Idempotency-Key on 503, like an idempotent method', function (): void { + $mock = (new MockTransport()) + ->push(new Response(503, [], 'boom')) + ->push(new Response(201, [], 'created')); + + $rt = buildRetryingTransport($mock, new RetryPolicy(maxRetries: 2, baseDelay: 0.001)); + + $r = $rt->send(new Request( + 'POST', + 'https://api.nfe.io', + '/v1/x', + headers: ['Idempotency-Key' => 'abc-123'], + )); + + expect($r->statusCode)->toBe(201); + expect(count($mock->sent()))->toBe(2); +}); diff --git a/tests/Resource/ServiceInvoicesResourceTest.php b/tests/Resource/ServiceInvoicesResourceTest.php index f19d98f..7faad07 100644 --- a/tests/Resource/ServiceInvoicesResourceTest.php +++ b/tests/Resource/ServiceInvoicesResourceTest.php @@ -9,6 +9,7 @@ use Nfe\Http\Response; use Nfe\Http\RetryPolicy; use Nfe\Resource\Dto\ServiceInvoices\ServiceInvoice; +use Nfe\Resource\ServiceInvoicesResource; use Nfe\Response\ServiceInvoiceIssued; use Nfe\Response\ServiceInvoicePending; use Nfe\Tests\Support\MockTransport; @@ -127,3 +128,101 @@ function buildSvcClient(MockTransport $mock): Client expect($list->page->pageIndex)->toBe(1); expect($list->page->pageCount)->toBe(2); }); + +// --- externalId reconciliation (add-safe-retry-idempotency) --- + +it('hydrates externalId from a retrieve payload', function (): void { + $mock = (new MockTransport())->push(new Response( + 200, + [], + '{"id":"inv-1","externalId":"pedido-9","flowStatus":"Issued"}', + )); + $client = buildSvcClient($mock); + + $invoice = $client->serviceInvoices->retrieve('abc', 'inv-1'); + + expect($invoice->externalId)->toBe('pedido-9'); +}); + +it('findByExternalId returns the invoice from the collection envelope', function (): void { + $mock = (new MockTransport())->push(new Response( + 200, + [], + '{"serviceInvoices":[{"id":"inv-1","externalId":"pedido-9","flowStatus":"Issued"}],"page":1}', + )); + $client = buildSvcClient($mock); + + $invoice = $client->serviceInvoices->findByExternalId('abc', 'pedido-9'); + + expect($invoice)->toBeInstanceOf(ServiceInvoice::class); + expect($invoice?->id)->toBe('inv-1'); + expect($invoice?->externalId)->toBe('pedido-9'); + + $sent = $mock->lastRequest(); + expect($sent?->method)->toBe('GET'); + expect($sent?->path)->toBe('/v1/companies/abc/serviceinvoices/external/pedido-9'); +}); + +it('findByExternalId returns null on an empty collection (miss)', function (): void { + $mock = (new MockTransport())->push(new Response(200, [], '{"serviceInvoices":[],"page":1}')); + $client = buildSvcClient($mock); + + expect($client->serviceInvoices->findByExternalId('abc', 'pedido-x'))->toBeNull(); +}); + +it('findByExternalId accepts a bare single-object body (YAML fallback)', function (): void { + $mock = (new MockTransport())->push(new Response( + 200, + [], + '{"id":"inv-2","externalId":"pedido-x","flowStatus":"Issued"}', + )); + $client = buildSvcClient($mock); + + $invoice = $client->serviceInvoices->findByExternalId('abc', 'pedido-x'); + + expect($invoice?->id)->toBe('inv-2'); +}); + +it('findByExternalId url-encodes the externalId in the path', function (): void { + $mock = (new MockTransport())->push(new Response(200, [], '{"serviceInvoices":[],"page":1}')); + $client = buildSvcClient($mock); + + $client->serviceInvoices->findByExternalId('abc', 'pedido/2026 #7'); + + expect($mock->lastRequest()?->path)->toBe('/v1/companies/abc/serviceinvoices/external/pedido%2F2026%20%237'); +}); + +it('findByExternalId rejects an empty externalId synchronously', function (): void { + $mock = new MockTransport(); + $client = buildSvcClient($mock); + + expect(fn() => $client->serviceInvoices->findByExternalId('abc', ' ')) + ->toThrow(InvalidRequestException::class); + expect($mock->sent())->toHaveCount(0); +}); + +it('isDuplicateExternalId recognizes the live duplicate rejection', function (): void { + $e = new InvalidRequestException( + 'API request failed with HTTP 400', + statusCode: 400, + responseBody: '"service invoice with external id (pedido-9) already exists"', + ); + + expect(ServiceInvoicesResource::isDuplicateExternalId($e))->toBeTrue(); +}); + +it('isDuplicateExternalId ignores an unrelated 400', function (): void { + $e = new InvalidRequestException( + 'API request failed with HTTP 400', + statusCode: 400, + responseBody: '"borrower.federalTaxNumber borrower federal tax number is not valid"', + ); + + expect(ServiceInvoicesResource::isDuplicateExternalId($e))->toBeFalse(); +}); + +it('isDuplicateExternalId ignores non-400 statuses', function (): void { + $e = new NotFoundException('not found', statusCode: 404); + + expect(ServiceInvoicesResource::isDuplicateExternalId($e))->toBeFalse(); +}); diff --git a/tests/Support/NullPsrRequest.php b/tests/Support/NullPsrRequest.php new file mode 100644 index 0000000..b88f3d7 --- /dev/null +++ b/tests/Support/NullPsrRequest.php @@ -0,0 +1,108 @@ +> */ + public function getHeaders(): array + { + return []; + } + + public function hasHeader(string $name): bool + { + return false; + } + + /** @return array */ + public function getHeader(string $name): array + { + return []; + } + + public function getHeaderLine(string $name): string + { + return ''; + } + + public function withHeader(string $name, mixed $value): RequestInterface + { + return $this; + } + + public function withAddedHeader(string $name, mixed $value): RequestInterface + { + return $this; + } + + public function withoutHeader(string $name): RequestInterface + { + return $this; + } + + public function getBody(): StreamInterface + { + throw new LogicException('NullPsrRequest::getBody() should not be called in tests.'); + } + + public function withBody(StreamInterface $body): RequestInterface + { + return $this; + } + + public function getRequestTarget(): string + { + return '/'; + } + + public function withRequestTarget(string $requestTarget): RequestInterface + { + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function withMethod(string $method): RequestInterface + { + return $this; + } + + public function getUri(): UriInterface + { + throw new LogicException('NullPsrRequest::getUri() should not be called in tests.'); + } + + public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface + { + return $this; + } +} diff --git a/tests/Support/NullPsrRequestFactory.php b/tests/Support/NullPsrRequestFactory.php new file mode 100644 index 0000000..3b37918 --- /dev/null +++ b/tests/Support/NullPsrRequestFactory.php @@ -0,0 +1,19 @@ +toThrow; + } +}