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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
}
```

Expand Down
16 changes: 16 additions & 0 deletions lib/Exception/NetworkException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Chip\Exception;

/**
* A request failed before any HTTP response existed — DNS failure, connection
* refused, TLS handshake error or timeout.
*
* This is still a `ChipApiException`, so `catch (ChipApiException $e)` covers
* every failure the SDK can produce. `getCode()` is 0 (there is no HTTP status)
* and `getResponseBody()` is null; the transport exception is available through
* `getPrevious()`.
*/
class NetworkException extends ChipApiException
{
}
11 changes: 11 additions & 0 deletions lib/Http/GuzzleClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

use Chip\Exception\AuthenticationException;
use Chip\Exception\ClientException;
use Chip\Exception\NetworkException;
use Chip\Exception\NotFoundException;
use Chip\Exception\ServerException;
use Chip\Exception\ValidationException;
use GuzzleHttp\Exception\ClientException as GuzzleClientException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\ServerException as GuzzleServerException;
use GuzzleHttp\Exception\TransferException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

Expand Down Expand Up @@ -84,6 +86,15 @@ public function request(string $method, string $endpoint, array $options = []):
]);

throw new ServerException($message, $statusCode, $body, $e);
} catch (TransferException $e) {
// No HTTP response existed: DNS failure, connection refused, TLS
// error or timeout. Without this the raw Guzzle exception escapes,
// so callers catching ChipApiException never see it.
$this->logger->error('CHIP API network error', [
'message' => $e->getMessage(),
]);

throw new NetworkException('Network error: ' . $e->getMessage(), 0, null, $e);
}

$body = (string) $response->getBody()->getContents();
Expand Down
84 changes: 84 additions & 0 deletions tests/NetworkExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;

/**
* A failure that never produced an HTTP response (DNS, connection refused, TLS,
* timeout) used to escape as a raw Guzzle exception, so `catch (ChipApiException)`
* never fired. It must now be a NetworkException.
*/
final class NetworkExceptionTest extends TestCase
{
private const URI = 'https://gate.chip-in.asia/api/v1/purchases/x/';

/**
* @param array<int, Response|\Throwable> $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');
}
}
Loading