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..3c236be --- /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'); + } +}