From 20f8d15c84bad19d415371f9398b1dbd0dbb02ce Mon Sep 17 00:00:00 2001 From: Wan Zulkarnain Date: Fri, 23 Oct 2026 18:36:18 +0200 Subject: [PATCH 1/2] feat: wrap transport failures in ChipApiException A DNS failure, refused connection, TLS error or timeout raised the raw GuzzleHttp exception, so catch (ChipApiException $e) never saw it and the caller had to know the transport library. GuzzleClient now catches TransferException and throws Chip\Exception\NetworkException (code 0, getResponseBody() null, transport exception preserved as the previous). Network failures are deliberately not retried: an error raised before a response existed does not prove the request never reached the gateway, so retrying a POST could duplicate it. Tests cover the mapping, the no-retry contract (one attempt), and that 4xx/5xx keep their own exception types. --- CHANGELOG.md | 6 +++ README.md | 3 ++ lib/Exception/NetworkException.php | 16 ++++++ lib/Http/GuzzleClient.php | 11 ++++ tests/NetworkExceptionTest.php | 84 ++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+) create mode 100644 lib/Exception/NetworkException.php create mode 100644 tests/NetworkExceptionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7fec7..ccc725f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.2.0] - 2026-10-23 + ### Fixed - Paginated list models no longer throw a `TypeError` when the gateway returns an entry that is not an object: `webhooks`, `company_statements`, `clients`, `client recurring tokens`, `billing_templates` and `billing template clients` now skip non-array entries instead of failing inside the `fromArray()` closure (`Argument #1 ($w) must be of type array, string given`). - `account->balance()` and `account->turnover()` return `[]` when the response is not an object, instead of a `TypeError` from the declared `array` return type (`Return value must be of type array, string returned`). +### Added + +- `Chip\Exception\NetworkException` — thrown when a request fails before any HTTP response exists (DNS failure, connection refused, TLS error, timeout). It extends `ChipApiException`, so `catch (ChipApiException $e)` now covers every failure the SDK can raise; previously the raw Guzzle transport exception escaped. `getCode()` is `0`, `getResponseBody()` is `null`, and the transport exception is available via `getPrevious()`. Network failures are deliberately **not** retried (a connection error on a `POST` may have reached the server). + ## [2.1.1] - 2026-10-23 ### Fixed diff --git a/README.md b/README.md index 42ec331..fb02209 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,7 @@ use Chip\Exception\NotFoundException; use Chip\Exception\ValidationException; use Chip\Exception\ServerException; use Chip\Exception\ClientException; +use Chip\Exception\NetworkException; try { $purchase = $chip->purchases->get('nonexistent_id'); @@ -322,6 +323,8 @@ try { // 5xx - Server error } catch (ClientException $e) { // Other 4xx errors +} catch (NetworkException $e) { + // DNS / connection / TLS / timeout — no HTTP response, getCode() is 0 } ``` diff --git a/lib/Exception/NetworkException.php b/lib/Exception/NetworkException.php new file mode 100644 index 0000000..dd5fbaa --- /dev/null +++ b/lib/Exception/NetworkException.php @@ -0,0 +1,16 @@ +logger->error('CHIP API network error', [ + 'message' => $e->getMessage(), + ]); + + throw new NetworkException('Network error: ' . $e->getMessage(), 0, null, $e); } $body = (string) $response->getBody()->getContents(); diff --git a/tests/NetworkExceptionTest.php b/tests/NetworkExceptionTest.php new file mode 100644 index 0000000..9f9bc06 --- /dev/null +++ b/tests/NetworkExceptionTest.php @@ -0,0 +1,84 @@ + $queue + * @return array{0: \Chip\ChipApi, 1: object{count: int}} + */ + private function api(array $queue): array + { + $counter = new class { + public int $count = 0; + }; + + $stack = HandlerStack::create(new MockHandler($queue)); + $stack->push(Middleware::mapRequest(function (RequestInterface $request) use ($counter): RequestInterface { + ++$counter->count; + + return $request; + })); + + return [ + new \Chip\ChipApi('brand', 'key', 'https://gate.chip-in.asia/api/v1/', ['handler' => $stack]), + $counter, + ]; + } + + public function testConnectFailureBecomesCatchableChipApiException(): void + { + $transport = new ConnectException('Connection timed out', new Request('GET', self::URI)); + [$api] = $this->api([$transport]); + + try { + $api->purchases->get('x'); + $this->fail('Expected NetworkException'); + } catch (\Chip\Exception\NetworkException $e) { + $this->assertInstanceOf(\Chip\Exception\ChipApiException::class, $e); + $this->assertStringContainsString('Connection timed out', $e->getMessage()); + $this->assertSame(0, $e->getCode(), 'a network failure has no HTTP status'); + $this->assertNull($e->getResponseBody()); + $this->assertSame($transport, $e->getPrevious(), 'the transport exception stays available'); + } + } + + public function testNetworkFailureIsNotRetried(): void + { + $transport = new ConnectException('Connection refused', new Request('GET', self::URI)); + [$api, $counter] = $this->api([$transport]); + + try { + $api->purchases->get('x'); + $this->fail('Expected NetworkException'); + } catch (\Chip\Exception\NetworkException $e) { + $this->assertSame(1, $counter->count, 'network failures fail fast — retrying a POST is not safe'); + } + } + + public function testHttpErrorsStillMapToTheirOwnExceptions(): void + { + [$api] = $this->api([new Response(404, [], '{"detail": "Purchase not found"}')]); + + $this->expectException(\Chip\Exception\NotFoundException::class); + + $api->purchases->get('x'); + } +} From 4242cea847059971a4ecaaf853454727042d7679 Mon Sep 17 00:00:00 2001 From: Wan Zulkarnain Date: Fri, 23 Oct 2026 18:37:03 +0200 Subject: [PATCH 2/2] style: php-cs-fixer --- tests/NetworkExceptionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/NetworkExceptionTest.php b/tests/NetworkExceptionTest.php index 9f9bc06..3c236be 100644 --- a/tests/NetworkExceptionTest.php +++ b/tests/NetworkExceptionTest.php @@ -26,7 +26,7 @@ final class NetworkExceptionTest extends TestCase */ private function api(array $queue): array { - $counter = new class { + $counter = new class () { public int $count = 0; };