diff --git a/src/Exceptions/UnexpectedStatusCodeException.php b/src/Exceptions/UnexpectedStatusCodeException.php new file mode 100644 index 00000000..de2ba30d --- /dev/null +++ b/src/Exceptions/UnexpectedStatusCodeException.php @@ -0,0 +1,127 @@ +request = $request; + $this->response = $response; + + $body = (string) $response->getBody(); + $decoded = json_decode($body, true); + + $error = []; + if (is_array($decoded) && isset($decoded['error']) && is_array($decoded['error'])) { + $error = $decoded['error']; + } + + $this->contents = [ + 'message' => ($error['message'] ?? null) ?: "Unexpected status code: {$statusCode}", + 'type' => $error['type'] ?? null, + 'code' => $error['code'] ?? null, + ]; + + $message = $this->contents['message']; + if (is_array($message)) { + $message = implode(PHP_EOL, $message); + } + + parent::__construct($message, $statusCode); + } + + /** + * Returns the HTTP status code of the response. + */ + public function getStatusCode(): int + { + return $this->response->getStatusCode(); + } + + /** + * Returns the error message. + */ + public function getErrorMessage(): string + { + return $this->getMessage(); + } + + /** + * Returns the error code if available. + */ + public function getErrorCode(): string|int|null + { + return $this->contents['code'] ?? null; + } + + /** + * Returns the error type if available. + */ + public function getErrorType(): ?string + { + return $this->contents['type'] ?? null; + } + + /** + * Returns the request that was made. + */ + public function getRequest(): RequestInterface + { + return $this->request; + } + + /** + * Returns the response that was received. + */ + public function getResponse(): ResponseInterface + { + return $this->response; + } + + /** + * Returns a string representation of the request. + */ + public function getRequestDetails(): string + { + return sprintf( + 'Request: %s %s', + $this->request->getMethod(), + $this->request->getUri() + ); + } + + /** + * Returns a string representation of the response. + */ + public function getResponseDetails(): string + { + return sprintf( + 'Response: %d %s', + $this->response->getStatusCode(), + $this->response->getReasonPhrase() + ); + } +} diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index a2206658..366027b9 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -11,6 +11,7 @@ use OpenAI\Enums\Transporter\ContentType; use OpenAI\Exceptions\ErrorException; use OpenAI\Exceptions\TransporterException; +use OpenAI\Exceptions\UnexpectedStatusCodeException; use OpenAI\Exceptions\UnserializableResponse; use OpenAI\ValueObjects\Transporter\BaseUri; use OpenAI\ValueObjects\Transporter\Headers; @@ -19,6 +20,7 @@ use OpenAI\ValueObjects\Transporter\Response; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; /** @@ -48,6 +50,8 @@ public function requestObject(Payload $payload): Response $response = $this->sendRequest(fn (): \Psr\Http\Message\ResponseInterface => $this->client->sendRequest($request)); + $this->throwIfNotSuccessfulStatusCode($response, $request); + $contents = (string) $response->getBody(); if (str_contains($response->getHeaderLine('Content-Type'), ContentType::TEXT_PLAIN->value)) { @@ -75,6 +79,8 @@ public function requestContent(Payload $payload): string $response = $this->sendRequest(fn (): \Psr\Http\Message\ResponseInterface => $this->client->sendRequest($request)); + $this->throwIfNotSuccessfulStatusCode($response, $request); + $contents = (string) $response->getBody(); $this->throwIfJsonError($response, $contents); @@ -91,6 +97,8 @@ public function requestStream(Payload $payload): ResponseInterface $response = $this->sendRequest(fn () => ($this->streamHandler)($request)); + $this->throwIfNotSuccessfulStatusCode($response, $request); + $this->throwIfJsonError($response, $response); return $response; @@ -136,4 +144,13 @@ private function throwIfJsonError(ResponseInterface $response, string|ResponseIn throw new UnserializableResponse($jsonException); } } + + private function throwIfNotSuccessfulStatusCode(ResponseInterface $response, RequestInterface $request): void + { + $statusCode = $response->getStatusCode(); + + if ($statusCode < 200 || $statusCode >= 300) { + throw new UnexpectedStatusCodeException($statusCode, $request, $response); + } + } } diff --git a/tests/Arch.php b/tests/Arch.php index f7c8b14a..9ddeadc4 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -19,6 +19,7 @@ ->expect('OpenAI\Exceptions') ->toOnlyUse([ 'Psr\Http\Client', + 'Psr\Http\Message', ])->toImplement(Throwable::class); test('resources')->expect('OpenAI\Resources')->toOnlyUse([ diff --git a/tests/Transporters/HttpTransporter.php b/tests/Transporters/HttpTransporter.php index 4fd772af..dd053dc3 100644 --- a/tests/Transporters/HttpTransporter.php +++ b/tests/Transporters/HttpTransporter.php @@ -6,6 +6,7 @@ use OpenAI\Enums\Transporter\ContentType; use OpenAI\Exceptions\ErrorException; use OpenAI\Exceptions\TransporterException; +use OpenAI\Exceptions\UnexpectedStatusCodeException; use OpenAI\Exceptions\UnserializableResponse; use OpenAI\Transporters\HttpTransporter; use OpenAI\ValueObjects\ApiKey; @@ -101,7 +102,7 @@ ->andReturn($response); expect(fn () => $this->http->requestObject($payload)) - ->toThrow(function (ErrorException $e) { + ->toThrow(function (UnexpectedStatusCodeException $e) { expect($e->getMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://platform.openai.com.') ->and($e->getErrorMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://platform.openai.com.') ->and($e->getErrorCode())->toBe('invalid_api_key') @@ -128,7 +129,7 @@ ->andReturn($response); expect(fn () => $this->http->requestObject($payload)) - ->toThrow(function (ErrorException $e) { + ->toThrow(function (UnexpectedStatusCodeException $e) { expect($e->getMessage())->toBe('That model is currently overloaded with other requests. You can ...') ->and($e->getErrorMessage())->toBe('That model is currently overloaded with other requests. You can ...') ->and($e->getErrorCode())->toBeNull() @@ -154,7 +155,7 @@ ->andReturn($response); expect(fn () => $this->http->requestObject($payload)) - ->toThrow(function (ErrorException $e) { + ->toThrow(function (UnexpectedStatusCodeException $e) { expect($e->getMessage())->toBe('The model `gpt-42` does not exist') ->and($e->getErrorMessage())->toBe('The model `gpt-42` does not exist') ->and($e->getErrorCode())->toBeNull() @@ -180,7 +181,7 @@ ->andReturn($response); expect(fn () => $this->http->requestObject($payload)) - ->toThrow(function (ErrorException $e) { + ->toThrow(function (UnexpectedStatusCodeException $e) { expect($e->getMessage())->toBe('The model `gpt-42` does not exist') ->and($e->getErrorMessage())->toBe('The model `gpt-42` does not exist') ->and($e->getErrorCode())->toBe(123) @@ -206,7 +207,7 @@ ->andReturn($response); expect(fn () => $this->http->requestObject($payload)) - ->toThrow(function (ErrorException $e) { + ->toThrow(function (UnexpectedStatusCodeException $e) { expect($e->getMessage())->toBe('You exceeded your current quota, please check') ->and($e->getErrorMessage())->toBe('You exceeded your current quota, please check') ->and($e->getErrorCode())->toBe('quota_exceeded') @@ -235,7 +236,7 @@ ->andReturn($response); expect(fn () => $this->http->requestObject($payload)) - ->toThrow(function (ErrorException $e) { + ->toThrow(function (UnexpectedStatusCodeException $e) { expect($e->getMessage())->toBe('Invalid schema for function \'get_current_weather\':'.PHP_EOL.'In context=(\'properties\', \'location\'), array schema missing items') ->and($e->getErrorMessage())->toBe('Invalid schema for function \'get_current_weather\':'.PHP_EOL.'In context=(\'properties\', \'location\'), array schema missing items') ->and($e->getErrorCode())->toBeNull() @@ -261,9 +262,8 @@ ->andReturn($response); expect(fn () => $this->http->requestObject($payload)) - ->toThrow(function (ErrorException $e) { - expect($e->getMessage())->toBe('invalid_api_key') - ->and($e->getErrorMessage())->toBe('invalid_api_key') + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Unexpected status code: 404') ->and($e->getErrorCode())->toBe('invalid_api_key') ->and($e->getErrorType())->toBe('invalid_request_error'); }); @@ -287,9 +287,8 @@ ->andReturn($response); expect(fn () => $this->http->requestObject($payload)) - ->toThrow(function (ErrorException $e) { - expect($e->getMessage())->toBe('123') - ->and($e->getErrorMessage())->toBe('123') + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Unexpected status code: 404') ->and($e->getErrorCode())->toBe(123) ->and($e->getErrorType())->toBe('invalid_request_error'); }); @@ -313,9 +312,8 @@ ->andReturn($response); expect(fn () => $this->http->requestObject($payload)) - ->toThrow(function (ErrorException $e) { - expect($e->getMessage())->toBe('Unknown error') - ->and($e->getErrorMessage())->toBe('Unknown error') + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Unexpected status code: 404') ->and($e->getErrorCode())->toBeNull() ->and($e->getErrorType())->toBe('invalid_request_error'); }); @@ -472,7 +470,7 @@ ->andReturn($response); expect(fn () => $this->http->requestContent($payload)) - ->toThrow(function (ErrorException $e) { + ->toThrow(function (UnexpectedStatusCodeException $e) { expect($e->getMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://platform.openai.com.') ->and($e->getErrorMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://platform.openai.com.') ->and($e->getErrorCode())->toBe('invalid_api_key') @@ -524,10 +522,171 @@ ->andReturn($response); expect(fn () => $this->http->requestStream($payload)) - ->toThrow(function (ErrorException $e) { + ->toThrow(function (UnexpectedStatusCodeException $e) { expect($e->getMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://platform.openai.com.') ->and($e->getErrorMessage())->toBe('Incorrect API key provided: foo. You can find your API key at https://platform.openai.com.') ->and($e->getErrorCode())->toBe('invalid_api_key') ->and($e->getErrorType())->toBe('invalid_request_error'); }); }); + +test('request stream client error 400', function () { + $payload = Payload::create('completions', []); + + $response = new Response(400, [], json_encode([ + 'error' => [ + 'message' => 'Bad Request', + 'type' => 'client_error', + 'param' => null, + ], + ])); + + $this->client + ->shouldReceive('sendAsyncRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->requestStream($payload)) + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Bad Request') + ->and($e->getCode())->toBe(400); + }); +}); + +test('request stream client error 401', function () { + $payload = Payload::create('completions', []); + + $response = new Response(401, [], json_encode([ + 'error' => [ + 'message' => 'Unauthorized', + 'type' => 'client_error', + 'param' => null, + ], + ])); + + $this->client + ->shouldReceive('sendAsyncRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->requestStream($payload)) + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Unauthorized') + ->and($e->getCode())->toBe(401); + }); +}); + +test('request stream client error 403', function () { + $payload = Payload::create('completions', []); + + $response = new Response(403, [], json_encode([ + 'error' => [ + 'message' => 'Forbidden', + 'type' => 'client_error', + 'param' => null, + ], + ])); + + $this->client + ->shouldReceive('sendAsyncRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->requestStream($payload)) + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Forbidden') + ->and($e->getCode())->toBe(403); + }); +}); + +test('request stream client error 404', function () { + $payload = Payload::create('completions', []); + + $response = new Response(404, [], json_encode([ + 'error' => [ + 'message' => 'Not Found', + 'type' => 'client_error', + 'param' => null, + ], + ])); + + $this->client + ->shouldReceive('sendAsyncRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->requestStream($payload)) + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Not Found') + ->and($e->getCode())->toBe(404); + }); +}); + +test('request stream client error 422', function () { + $payload = Payload::create('completions', []); + + $response = new Response(422, [], json_encode([ + 'error' => [ + 'message' => 'Unprocessable Entity', + 'type' => 'client_error', + 'param' => null, + ], + ])); + + $this->client + ->shouldReceive('sendAsyncRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->requestStream($payload)) + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Unprocessable Entity') + ->and($e->getCode())->toBe(422); + }); +}); + +test('request stream client error 429', function () { + $payload = Payload::create('completions', []); + + $response = new Response(429, [], json_encode([ + 'error' => [ + 'message' => 'Too Many Requests', + 'type' => 'client_error', + 'param' => null, + ], + ])); + + $this->client + ->shouldReceive('sendAsyncRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->requestStream($payload)) + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Too Many Requests') + ->and($e->getCode())->toBe(429); + }); +}); + +test('request stream client error 500', function () { + $payload = Payload::create('completions', []); + + $response = new Response(500, [], json_encode([ + 'error' => [ + 'message' => 'Internal Server Error', + 'type' => 'client_error', + 'param' => null, + ], + ])); + + $this->client + ->shouldReceive('sendAsyncRequest') + ->once() + ->andReturn($response); + + expect(fn () => $this->http->requestStream($payload)) + ->toThrow(function (UnexpectedStatusCodeException $e) { + expect($e->getMessage())->toBe('Internal Server Error') + ->and($e->getCode())->toBe(500); + }); +});