From ac1539c42f2b35d517ee2c2a5c8ad22d39573011 Mon Sep 17 00:00:00 2001 From: RJ Date: Fri, 25 Sep 2026 17:44:44 -0700 Subject: [PATCH] Fix PSR client support and quota retries --- CHANGELOG.md | 8 ++++++ README.md | 10 +++++--- src/Client.php | 25 +++++++++++++++++-- tests/ClientTest.php | 58 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7197b08..a415406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/). --- +## [Unreleased] + +### Fixed + +- Support PSR-18 clients that do not provide Guzzle's `request()` method. +- Stop retrying HTTP 429 quota responses. +- Correct the year-filtered makes example in the README. + ## [0.1.0] - 2026-07-20 ### Added diff --git a/README.md b/README.md index bcb137d..17d53cf 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ By default the SDK uses: | API Version | `v1` | | Timeout | 15 seconds | | Connect Timeout | 5 seconds | -| Retries | 2 | +| Retries | 2 additional attempts for connection failures and HTTP 5xx | To customize these values: @@ -73,6 +73,10 @@ $config = new Configuration( $carList = new CarListApi($config); ``` +You can pass a PSR-18 HTTP client as the second constructor argument. When using a +non-Guzzle client, configure its timeouts and TLS verification on that client; +the SDK's transport options are applied to Guzzle clients. + --- # Quick Start @@ -93,7 +97,7 @@ Retrieve all makes. ```php $makes = $carList ->automotive() - ->makes(2026) + ->makesByYear(2026) ->data; ``` @@ -256,7 +260,7 @@ Requests automatically include a User-Agent identifying the SDK. Example: ```text -codebyray/carlistapi-php-sdk/0.1.0 +codebyray/carlistapi-php-sdk/ ``` You may override the User-Agent if desired. diff --git a/src/Client.php b/src/Client.php index 6032d2f..54200bf 100644 --- a/src/Client.php +++ b/src/Client.php @@ -11,6 +11,8 @@ use CodebyRay\CarListApi\Exceptions\TransportException; use CodebyRay\CarListApi\Exceptions\ValidationException; use CodebyRay\CarListApi\Response\ApiResponse; +use GuzzleHttp\ClientInterface as GuzzleClientInterface; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\RequestOptions; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; @@ -54,7 +56,9 @@ private function send(string $method, string $path, array $options): ApiResponse while (true) { try { - $response = $this->http->request($method, $this->url($path), $options); + $response = $this->http instanceof GuzzleClientInterface + ? $this->http->request($method, $this->url($path), $options) + : $this->http->sendRequest($this->psrRequest($method, $path, $options)); } catch (ClientExceptionInterface $exception) { if ($attempt < $this->configuration->retryTimes) { $attempt++; @@ -131,9 +135,26 @@ private function url(string $path): string .'/'.ltrim($path, '/'); } + /** @param array $options */ + private function psrRequest(string $method, string $path, array $options): Request + { + $url = $this->url($path); + $query = http_build_query($options[RequestOptions::QUERY] ?? [], '', '&', PHP_QUERY_RFC3986); + + if ($query !== '') { + $url .= (str_contains($url, '?') ? '&' : '?').$query; + } + + $body = isset($options[RequestOptions::JSON]) + ? json_encode($options[RequestOptions::JSON], JSON_THROW_ON_ERROR) + : null; + + return new Request($method, $url, $options[RequestOptions::HEADERS], $body); + } + private function shouldRetry(ResponseInterface $response): bool { - return $response->getStatusCode() === 429 || $response->getStatusCode() >= 500; + return $response->getStatusCode() >= 500; } private function sleep(): void diff --git a/tests/ClientTest.php b/tests/ClientTest.php index a89aaa6..71d20aa 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -5,12 +5,16 @@ use CodebyRay\CarListApi\CarListApi; use CodebyRay\CarListApi\Configuration; use CodebyRay\CarListApi\Exceptions\AuthenticationException; +use CodebyRay\CarListApi\Exceptions\RateLimitException; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; +use Psr\Http\Client\ClientInterface as PsrClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; final class ClientTest extends TestCase { @@ -74,4 +78,58 @@ public function test_with_token_returns_a_new_sdk_instance(): void self::assertSame('first-token', $sdk->client()->configuration()->token); self::assertSame('second-token', $other->client()->configuration()->token); } + + public function test_it_supports_a_psr_only_http_client_for_get_and_post(): void + { + $http = new class implements PsrClientInterface + { + /** @var list */ + public array $requests = []; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + + return new Response(200, [], '{}'); + } + }; + + $sdk = new CarListApi(new Configuration(token: 'secret-token', baseUrl: 'https://example.test/api', retryTimes: 0), $http); + $sdk->client()->get('car-data/get-years/asc', ['make' => 'Land Rover']); + $sdk->vinDecoder()->decode('1HGCM82633A004352', 2003); + + self::assertSame('https://example.test/api/v1/car-data/get-years/asc?make=Land%20Rover', (string) $http->requests[0]->getUri()); + self::assertSame('Bearer secret-token', $http->requests[0]->getHeaderLine('Authorization')); + self::assertSame('POST', $http->requests[1]->getMethod()); + self::assertSame(['vin' => '1HGCM82633A004352', 'model_year' => 2003], json_decode((string) $http->requests[1]->getBody(), true)); + } + + public function test_it_does_not_retry_rate_limits(): void + { + $history = []; + $mock = new MockHandler([new Response(429, [], '{"error":"Quota exhausted"}')]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + $sdk = new CarListApi(new Configuration(token: 'token', retrySleepMs: 0), new Client(['handler' => $stack])); + + try { + $sdk->automotive()->years(); + self::fail('Expected a rate limit exception.'); + } catch (RateLimitException $e) { + self::assertSame('Quota exhausted', $e->getMessage()); + self::assertCount(1, $history); + } + } + + public function test_it_retries_transient_server_errors(): void + { + $history = []; + $mock = new MockHandler([new Response(500), new Response(200, [], '[]')]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + $sdk = new CarListApi(new Configuration(token: 'token', retryTimes: 1, retrySleepMs: 0), new Client(['handler' => $stack])); + + self::assertSame([], $sdk->automotive()->years()->data); + self::assertCount(2, $history); + } }