Skip to content

Commit 53d2193

Browse files
authored
Merge pull request #5 from LibreCodeCoop/feat/error-taxonomy
feat(exceptions): add typed exception hierarchy with machine error codes
2 parents 071aa2b + 7059244 commit 53d2193

File tree

8 files changed

+343
-22
lines changed

8 files changed

+343
-22
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Exception;
9+
10+
/**
11+
* Thrown when the SEFIN gateway rejects an NFS-e cancellation request.
12+
*/
13+
class CancellationException extends GatewayException
14+
{
15+
}

src/Exception/GatewayException.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Exception;
9+
10+
/**
11+
* Thrown when the SEFIN gateway responds with an HTTP error status (4xx/5xx).
12+
*
13+
* The upstream error payload is preserved to allow callers to surface
14+
* gateway-specific diagnostic information (e.g. fiscal rejection codes).
15+
*/
16+
class GatewayException extends NfseException
17+
{
18+
/**
19+
* @param array<string, mixed> $upstreamPayload Raw decoded response body from the gateway.
20+
*/
21+
public function __construct(
22+
string $message,
23+
public readonly NfseErrorCode $errorCode,
24+
public readonly int $httpStatus = 0,
25+
public readonly array $upstreamPayload = [],
26+
?\Throwable $previous = null,
27+
) {
28+
parent::__construct($message, 0, $previous);
29+
}
30+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Exception;
9+
10+
/**
11+
* Thrown when the SEFIN gateway rejects an NFS-e issuance request.
12+
*/
13+
class IssuanceException extends GatewayException
14+
{
15+
}

src/Exception/NetworkException.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Exception;
9+
10+
/**
11+
* Thrown when a network-level failure prevents communication with the gateway,
12+
* or when the gateway returns an unparseable response.
13+
*/
14+
class NetworkException extends NfseException
15+
{
16+
public function __construct(
17+
string $message,
18+
public readonly NfseErrorCode $errorCode = NfseErrorCode::NetworkFailure,
19+
?\Throwable $previous = null,
20+
) {
21+
parent::__construct($message, 0, $previous);
22+
}
23+
}

src/Exception/NfseErrorCode.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Exception;
9+
10+
/**
11+
* Machine-readable error codes for NFS-e operations.
12+
*
13+
* These codes provide a deterministic, framework-agnostic way to identify
14+
* the type of failure without relying on human-readable messages.
15+
*/
16+
enum NfseErrorCode: string
17+
{
18+
/** Connection to the SEFIN gateway could not be established. */
19+
case NetworkFailure = 'NETWORK_FAILURE';
20+
21+
/** Gateway returned a response that could not be parsed. */
22+
case InvalidResponse = 'INVALID_RESPONSE';
23+
24+
/** Gateway rejected the NFS-e issuance request (HTTP 4xx/5xx). */
25+
case IssuanceRejected = 'ISSUANCE_REJECTED';
26+
27+
/** Gateway rejected the NFS-e cancellation request (HTTP 4xx/5xx). */
28+
case CancellationRejected = 'CANCELLATION_REJECTED';
29+
30+
/** Gateway returned an error when querying an NFS-e (HTTP 4xx/5xx). */
31+
case QueryFailed = 'QUERY_FAILED';
32+
}

src/Exception/QueryException.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
4+
// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+
declare(strict_types=1);
7+
8+
namespace LibreCodeCoop\NfsePHP\Exception;
9+
10+
/**
11+
* Thrown when the SEFIN gateway returns an error for an NFS-e query request.
12+
*/
13+
class QueryException extends GatewayException
14+
{
15+
}

src/Http/NfseClient.php

Lines changed: 87 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
use LibreCodeCoop\NfsePHP\Contracts\XmlSignerInterface;
1313
use LibreCodeCoop\NfsePHP\Dto\DpsData;
1414
use LibreCodeCoop\NfsePHP\Dto\ReceiptData;
15-
use LibreCodeCoop\NfsePHP\Exception\NfseException;
15+
use LibreCodeCoop\NfsePHP\Exception\CancellationException;
16+
use LibreCodeCoop\NfsePHP\Exception\IssuanceException;
17+
use LibreCodeCoop\NfsePHP\Exception\NetworkException;
18+
use LibreCodeCoop\NfsePHP\Exception\NfseErrorCode;
19+
use LibreCodeCoop\NfsePHP\Exception\QueryException;
1620
use LibreCodeCoop\NfsePHP\Xml\DpsSigner;
1721
use LibreCodeCoop\NfsePHP\Xml\XmlBuilder;
1822

@@ -48,21 +52,48 @@ public function emit(DpsData $dps): ReceiptData
4852
$xml = (new XmlBuilder())->buildDps($dps);
4953
$signed = $this->signer->sign($xml, $dps->cnpjPrestador);
5054

51-
$response = $this->post('/dps', $signed);
55+
[$httpStatus, $body] = $this->post('/dps', $signed);
5256

53-
return $this->parseReceiptResponse($response);
57+
if ($httpStatus >= 400) {
58+
throw new IssuanceException(
59+
'SEFIN gateway rejected issuance (HTTP ' . $httpStatus . ')',
60+
NfseErrorCode::IssuanceRejected,
61+
$httpStatus,
62+
$body,
63+
);
64+
}
65+
66+
return $this->parseReceiptResponse($body);
5467
}
5568

5669
public function query(string $chaveAcesso): ReceiptData
5770
{
58-
$response = $this->get('/dps/' . $chaveAcesso);
71+
[$httpStatus, $body] = $this->get('/dps/' . $chaveAcesso);
72+
73+
if ($httpStatus >= 400) {
74+
throw new QueryException(
75+
'SEFIN gateway returned error for query (HTTP ' . $httpStatus . ')',
76+
NfseErrorCode::QueryFailed,
77+
$httpStatus,
78+
$body,
79+
);
80+
}
5981

60-
return $this->parseReceiptResponse($response);
82+
return $this->parseReceiptResponse($body);
6183
}
6284

6385
public function cancel(string $chaveAcesso, string $motivo): bool
6486
{
65-
$this->delete('/dps/' . $chaveAcesso, $motivo);
87+
[$httpStatus, $body] = $this->delete('/dps/' . $chaveAcesso, $motivo);
88+
89+
if ($httpStatus >= 400) {
90+
throw new CancellationException(
91+
'SEFIN gateway rejected cancellation (HTTP ' . $httpStatus . ')',
92+
NfseErrorCode::CancellationRejected,
93+
$httpStatus,
94+
$body,
95+
);
96+
}
6697

6798
return true;
6899
}
@@ -72,24 +103,24 @@ public function cancel(string $chaveAcesso, string $motivo): bool
72103
// -------------------------------------------------------------------------
73104

74105
/**
75-
* @return array<string, mixed>
106+
* @return array{int, array<string, mixed>}
76107
*/
77108
private function post(string $path, string $xmlPayload): array
78109
{
79110
$context = stream_context_create([
80111
'http' => [
81-
'method' => 'POST',
82-
'header' => "Content-Type: application/xml\r\nAccept: application/json\r\n",
83-
'content' => $xmlPayload,
112+
'method' => 'POST',
113+
'header' => "Content-Type: application/xml\r\nAccept: application/json\r\n",
114+
'content' => $xmlPayload,
84115
'ignore_errors' => true,
85116
],
86117
]);
87118

88-
return $this->request($path, $context);
119+
return $this->fetchAndDecode($path, $context);
89120
}
90121

91122
/**
92-
* @return array<string, mixed>
123+
* @return array{int, array<string, mixed>}
93124
*/
94125
private function get(string $path): array
95126
{
@@ -101,10 +132,13 @@ private function get(string $path): array
101132
],
102133
]);
103134

104-
return $this->request($path, $context);
135+
return $this->fetchAndDecode($path, $context);
105136
}
106137

107-
private function delete(string $path, string $motivo): void
138+
/**
139+
* @return array{int, array<string, mixed>}
140+
*/
141+
private function delete(string $path, string $motivo): array
108142
{
109143
$payload = json_encode(['motivo' => $motivo], JSON_THROW_ON_ERROR);
110144
$context = stream_context_create([
@@ -116,28 +150,59 @@ private function delete(string $path, string $motivo): void
116150
],
117151
]);
118152

119-
$this->request($path, $context);
153+
return $this->fetchAndDecode($path, $context);
120154
}
121155

122156
/**
123-
* @return array<string, mixed>
157+
* Perform the raw HTTP request and decode the JSON body.
158+
*
159+
* PHP sets $http_response_header in the calling scope when file_get_contents
160+
* uses an HTTP wrapper. We initialize it to [] so static analysers have a
161+
* typed baseline; the HTTP wrapper will overwrite it on a successful
162+
* connection, even when the server responds with 4xx/5xx.
163+
*
164+
* @return array{int, array<string, mixed>}
124165
*/
125-
private function request(string $path, mixed $context): array
166+
private function fetchAndDecode(string $path, mixed $context): array
126167
{
127-
$url = $this->baseUrl . $path;
128-
$body = file_get_contents($url, false, $context);
168+
$url = $this->baseUrl . $path;
169+
170+
$http_response_header = [];
171+
$body = file_get_contents($url, false, $context);
172+
$httpStatus = $this->parseHttpStatus($http_response_header);
129173

130174
if ($body === false) {
131-
throw new NfseException('Failed to connect to SEFIN gateway at ' . $url);
175+
throw new NetworkException('Failed to connect to SEFIN gateway at ' . $url);
132176
}
133177

134178
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
135179

136180
if (!is_array($decoded)) {
137-
throw new NfseException('Unexpected response format from SEFIN gateway');
181+
throw new NetworkException(
182+
'Unexpected response format from SEFIN gateway',
183+
NfseErrorCode::InvalidResponse,
184+
);
185+
}
186+
187+
return [$httpStatus, $decoded];
188+
}
189+
190+
/**
191+
* Extract the HTTP status code from the first response header line.
192+
*
193+
* @param list<string> $headers
194+
*/
195+
private function parseHttpStatus(array $headers): int
196+
{
197+
if (!isset($headers[0])) {
198+
return 0;
199+
}
200+
201+
if (preg_match('/HTTP\/[\d.]+ (\d{3})/', $headers[0], $m)) {
202+
return (int) $m[1];
138203
}
139204

140-
return $decoded;
205+
return 0;
141206
}
142207

143208
/**

0 commit comments

Comments
 (0)