From ee61fbbffc0fa58a6282d99db83e33f2d3335a62 Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Thu, 11 Sep 2025 16:37:58 -0400 Subject: [PATCH 1/4] fix: handle data.error and data.error.error --- src/Exceptions/ErrorException.php | 8 ++++---- src/Transporters/HttpTransporter.php | 6 ++++-- tests/Transporters/HttpTransporter.php | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/Exceptions/ErrorException.php b/src/Exceptions/ErrorException.php index 5f28481b..70b1685d 100644 --- a/src/Exceptions/ErrorException.php +++ b/src/Exceptions/ErrorException.php @@ -14,12 +14,12 @@ final class ErrorException extends Exception /** * Creates a new Exception instance. * - * @param array{message: string|array, type: ?string, code: string|int|null} $contents + * @param array{message?: string|array, type?: ?string, code?: string|int|null} $contents */ public function __construct(private readonly array $contents, public readonly ResponseInterface $response) { $this->statusCode = $response->getStatusCode(); - $message = ($contents['message'] ?: (string) $this->contents['code']) ?: 'Unknown error'; + $message = ($contents['message'] ?? null) ?: (string) ($this->contents['code'] ?? null) ?: 'Unknown error'; if (is_array($message)) { $message = implode(PHP_EOL, $message); @@ -51,7 +51,7 @@ public function getErrorMessage(): string */ public function getErrorType(): ?string { - return $this->contents['type']; + return $this->contents['type'] ?? null; } /** @@ -59,6 +59,6 @@ public function getErrorType(): ?string */ public function getErrorCode(): string|int|null { - return $this->contents['code']; + return $this->contents['code'] ?? null; } } diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index 2a571f76..b5ad6346 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -162,11 +162,13 @@ private function throwIfJsonError(ResponseInterface $response, string|ResponseIn } try { - /** @var array{error?: array{message: string|array, type: string, code: string}} $data */ + /** @var array{error?: string|array{message: string|array, type: string, code: string}} $data */ $data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + // As shown in #681 - we can have a double nested error object of data.error.error or data.error. if (isset($data['error'])) { - throw new ErrorException($data['error'], $response); + $errorPayload = is_string($data['error']) ? ['message' => $data['error']] : $data['error']; + throw new ErrorException($errorPayload, $response); } } catch (JsonException $jsonException) { throw new UnserializableResponse($jsonException, $response); diff --git a/tests/Transporters/HttpTransporter.php b/tests/Transporters/HttpTransporter.php index 688cf186..7461eb65 100644 --- a/tests/Transporters/HttpTransporter.php +++ b/tests/Transporters/HttpTransporter.php @@ -190,6 +190,27 @@ }); })->with('request methods'); +test('error code may be string for no permission', function (string $requestMethod) { + $payload = Payload::create('completions', ['model' => 'gpt-42']); + + $response = new Response(404, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ + 'error' => 'You have insufficient permissions for this operation. Missing scopes: api.model.read', + ])); + + $this->client + ->shouldReceive('sendRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->$requestMethod($payload)) + ->toThrow(function (ErrorException $e) { + expect($e->getMessage())->toBe('You have insufficient permissions for this operation. Missing scopes: api.model.read') + ->and($e->getErrorMessage())->toBe('You have insufficient permissions for this operation. Missing scopes: api.model.read') + ->and($e->getErrorCode())->toBeNull() + ->and($e->getErrorType())->toBeNull(); + }); +})->with('request methods'); + test('error type may be null on 429', function (string $requestMethod) { $payload = Payload::list('models'); From 054b8758ae47a322c8e850ea1f72b0ee45e50c1a Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Fri, 12 Sep 2025 06:40:52 -0400 Subject: [PATCH 2/4] chore: move processing of error into exception class --- src/Exceptions/ErrorException.php | 7 +++++-- src/Transporters/HttpTransporter.php | 4 +--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Exceptions/ErrorException.php b/src/Exceptions/ErrorException.php index 70b1685d..7fda7299 100644 --- a/src/Exceptions/ErrorException.php +++ b/src/Exceptions/ErrorException.php @@ -14,11 +14,14 @@ final class ErrorException extends Exception /** * Creates a new Exception instance. * - * @param array{message?: string|array, type?: ?string, code?: string|int|null} $contents + * @param array{message?: string|array, type?: ?string, code?: string|int|null}|string $contents */ - public function __construct(private readonly array $contents, public readonly ResponseInterface $response) + public function __construct(private readonly string|array $contents, public readonly ResponseInterface $response) { $this->statusCode = $response->getStatusCode(); + + // Errors can be a string or an object with message, type, and code + $contents = is_string($contents) ? ['message' => $contents] : $contents; $message = ($contents['message'] ?? null) ?: (string) ($this->contents['code'] ?? null) ?: 'Unknown error'; if (is_array($message)) { diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index b5ad6346..ee69e91e 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -165,10 +165,8 @@ private function throwIfJsonError(ResponseInterface $response, string|ResponseIn /** @var array{error?: string|array{message: string|array, type: string, code: string}} $data */ $data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); - // As shown in #681 - we can have a double nested error object of data.error.error or data.error. if (isset($data['error'])) { - $errorPayload = is_string($data['error']) ? ['message' => $data['error']] : $data['error']; - throw new ErrorException($errorPayload, $response); + throw new ErrorException($data['error'], $response); } } catch (JsonException $jsonException) { throw new UnserializableResponse($jsonException, $response); From f5f4eec8b5d1415059e75ca5f9e6bd077d109e3e Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Fri, 12 Sep 2025 06:46:37 -0400 Subject: [PATCH 3/4] test: add test for error.message --- tests/Transporters/HttpTransporter.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Transporters/HttpTransporter.php b/tests/Transporters/HttpTransporter.php index 7461eb65..225ef0d1 100644 --- a/tests/Transporters/HttpTransporter.php +++ b/tests/Transporters/HttpTransporter.php @@ -211,6 +211,29 @@ }); })->with('request methods'); +test('error code may have only message', function (string $requestMethod) { + $payload = Payload::create('completions', ['model' => 'gpt-42']); + + $response = new Response(404, ['Content-Type' => 'application/json; charset=utf-8'], json_encode([ + 'error' => [ + 'message' => 'The engine is currently overloaded, please try again later', + ], + ])); + + $this->client + ->shouldReceive('sendRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->$requestMethod($payload)) + ->toThrow(function (ErrorException $e) { + expect($e->getMessage())->toBe('The engine is currently overloaded, please try again later') + ->and($e->getErrorMessage())->toBe('The engine is currently overloaded, please try again later') + ->and($e->getErrorCode())->toBeNull() + ->and($e->getErrorType())->toBeNull(); + }); +})->with('request methods'); + test('error type may be null on 429', function (string $requestMethod) { $payload = Payload::list('models'); From 2f3523004f80f71756a8647696f90126d9aea35b Mon Sep 17 00:00:00 2001 From: Connor Tumbleson Date: Fri, 12 Sep 2025 06:50:41 -0400 Subject: [PATCH 4/4] chore: use normalized var --- src/Exceptions/ErrorException.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Exceptions/ErrorException.php b/src/Exceptions/ErrorException.php index 7fda7299..c6468592 100644 --- a/src/Exceptions/ErrorException.php +++ b/src/Exceptions/ErrorException.php @@ -22,7 +22,7 @@ public function __construct(private readonly string|array $contents, public read // Errors can be a string or an object with message, type, and code $contents = is_string($contents) ? ['message' => $contents] : $contents; - $message = ($contents['message'] ?? null) ?: (string) ($this->contents['code'] ?? null) ?: 'Unknown error'; + $message = ($contents['message'] ?? null) ?: (string) ($contents['code'] ?? null) ?: 'Unknown error'; if (is_array($message)) { $message = implode(PHP_EOL, $message);