diff --git a/src/platform/src/Bridge/Anthropic/ResultConverter.php b/src/platform/src/Bridge/Anthropic/ResultConverter.php index 6916aa256..6d633df48 100644 --- a/src/platform/src/Bridge/Anthropic/ResultConverter.php +++ b/src/platform/src/Bridge/Anthropic/ResultConverter.php @@ -11,7 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Anthropic; -use Symfony\AI\Platform\Exception\RateLimitExceededException; +use Symfony\AI\Platform\Exception\HttpErrorHandler; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Result\RawHttpResult; @@ -41,11 +41,7 @@ public function convert(RawHttpResult|RawResultInterface $result, array $options { $response = $result->getObject(); - if (429 === $response->getStatusCode()) { - $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null; - $retryAfterValue = $retryAfter ? (float) $retryAfter : null; - throw new RateLimitExceededException($retryAfterValue); - } + HttpErrorHandler::handleHttpError($response); if ($options['stream'] ?? false) { return new StreamResult($this->convertStream($response)); diff --git a/src/platform/src/Bridge/Cerebras/ResultConverter.php b/src/platform/src/Bridge/Cerebras/ResultConverter.php index b11f7576b..f9e4c40cd 100644 --- a/src/platform/src/Bridge/Cerebras/ResultConverter.php +++ b/src/platform/src/Bridge/Cerebras/ResultConverter.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Cerebras; +use Symfony\AI\Platform\Exception\HttpErrorHandler; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model as BaseModel; use Symfony\AI\Platform\Result\RawHttpResult; @@ -36,8 +37,11 @@ public function supports(BaseModel $model): bool public function convert(RawHttpResult|RawResultInterface $result, array $options = []): ResultInterface { + $response = $result->getObject(); + HttpErrorHandler::handleHttpError($response); + if ($options['stream'] ?? false) { - return new StreamResult($this->convertStream($result->getObject())); + return new StreamResult($this->convertStream($response)); } $data = $result->getData(); diff --git a/src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php b/src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php index 9e7cafe1d..77fa85187 100644 --- a/src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php +++ b/src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php @@ -12,7 +12,7 @@ namespace Symfony\AI\Platform\Bridge\Gemini\Gemini; use Symfony\AI\Platform\Bridge\Gemini\Gemini; -use Symfony\AI\Platform\Exception\RateLimitExceededException; +use Symfony\AI\Platform\Exception\HttpErrorHandler; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Result\ChoiceResult; @@ -45,9 +45,7 @@ public function convert(RawResultInterface|RawHttpResult $result, array $options { $response = $result->getObject(); - if (429 === $response->getStatusCode()) { - throw new RateLimitExceededException(); - } + HttpErrorHandler::handleHttpError($response); if ($options['stream'] ?? false) { return new StreamResult($this->convertStream($response)); diff --git a/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php b/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php index 43592cb27..86461204b 100644 --- a/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php +++ b/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php @@ -12,9 +12,8 @@ namespace Symfony\AI\Platform\Bridge\OpenAi\Gpt; use Symfony\AI\Platform\Bridge\OpenAi\Gpt; -use Symfony\AI\Platform\Exception\AuthenticationException; use Symfony\AI\Platform\Exception\ContentFilterException; -use Symfony\AI\Platform\Exception\RateLimitExceededException; +use Symfony\AI\Platform\Exception\HttpErrorHandler; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Result\ChoiceResult; @@ -46,23 +45,7 @@ public function convert(RawResultInterface|RawHttpResult $result, array $options { $response = $result->getObject(); - if (401 === $response->getStatusCode()) { - $errorMessage = json_decode($response->getContent(false), true)['error']['message']; - throw new AuthenticationException($errorMessage); - } - - if (429 === $response->getStatusCode()) { - $headers = $response->getHeaders(false); - $resetTime = null; - - if (isset($headers['x-ratelimit-reset-requests'][0])) { - $resetTime = self::parseResetTime($headers['x-ratelimit-reset-requests'][0]); - } elseif (isset($headers['x-ratelimit-reset-tokens'][0])) { - $resetTime = self::parseResetTime($headers['x-ratelimit-reset-tokens'][0]); - } - - throw new RateLimitExceededException($resetTime); - } + HttpErrorHandler::handleHttpError($response); if ($options['stream'] ?? false) { return new StreamResult($this->convertStream($response)); @@ -208,25 +191,4 @@ private function convertToolCall(array $toolCall): ToolCall return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); } - - /** - * Converts OpenAI's reset time format (e.g. "1s", "6m0s", "2m30s") into seconds. - * - * Supported formats: - * - "1s" - * - "6m0s" - * - "2m30s" - */ - private static function parseResetTime(string $resetTime): float - { - $seconds = 0; - - if (preg_match('/^(?:(\d+)m)?(?:(\d+)s)?$/', $resetTime, $matches)) { - $minutes = isset($matches[1]) ? (int) $matches[1] : 0; - $secs = isset($matches[2]) ? (int) $matches[2] : 0; - $seconds = ($minutes * 60) + $secs; - } - - return (float) $seconds; - } } diff --git a/src/platform/src/Bridge/Perplexity/ResultConverter.php b/src/platform/src/Bridge/Perplexity/ResultConverter.php index 0a5cc7695..020fecb46 100644 --- a/src/platform/src/Bridge/Perplexity/ResultConverter.php +++ b/src/platform/src/Bridge/Perplexity/ResultConverter.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Perplexity; +use Symfony\AI\Platform\Exception\HttpErrorHandler; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Metadata\Metadata; use Symfony\AI\Platform\Model; @@ -38,8 +39,11 @@ public function supports(Model $model): bool public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface { + $response = $result->getObject(); + HttpErrorHandler::handleHttpError($response); + if ($options['stream'] ?? false) { - return new StreamResult($this->convertStream($result->getObject())); + return new StreamResult($this->convertStream($response)); } $data = $result->getData(); diff --git a/src/platform/src/Exception/HttpErrorHandler.php b/src/platform/src/Exception/HttpErrorHandler.php new file mode 100644 index 000000000..0c5f0849b --- /dev/null +++ b/src/platform/src/Exception/HttpErrorHandler.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Junaid Farooq + */ +final class HttpErrorHandler +{ + public static function handleHttpError(ResponseInterface $response): void + { + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 200 && $statusCode < 300) { + return; + } + + $errorMessage = self::extractErrorMessage($response); + + match ($statusCode) { + 401 => throw new AuthenticationException($errorMessage), + 404 => throw new NotFoundException($errorMessage), + 429 => throw new RateLimitExceededException(self::extractRetryAfter($response)), + 503 => throw new ServiceUnavailableException($errorMessage), + default => throw new RuntimeException(\sprintf('HTTP %d: %s', $statusCode, $errorMessage)), + }; + } + + private static function extractErrorMessage(ResponseInterface $response): string + { + $content = $response->getContent(false); + + if ('' === $content) { + return \sprintf('HTTP %d error', $response->getStatusCode()); + } + + $data = json_decode($content, true); + + if (!\is_array($data)) { + return $content; + } + + if (isset($data['error']['message'])) { + return $data['error']['message']; + } + + if (isset($data['error']) && \is_string($data['error'])) { + return $data['error']; + } + + return $data['message'] ?? $data['detail'] ?? $content; + } + + private static function extractRetryAfter(ResponseInterface $response): ?float + { + $headers = $response->getHeaders(false); + + if (isset($headers['retry-after'][0])) { + return (float) $headers['retry-after'][0]; + } + + if (isset($headers['x-ratelimit-reset-requests'][0])) { + return self::parseResetTime($headers['x-ratelimit-reset-requests'][0]); + } + + if (isset($headers['x-ratelimit-reset-tokens'][0])) { + return self::parseResetTime($headers['x-ratelimit-reset-tokens'][0]); + } + + return null; + } + + private static function parseResetTime(string $resetTime): ?float + { + if (is_numeric($resetTime)) { + return (float) $resetTime; + } + + if (preg_match('/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/', $resetTime, $matches)) { + $hours = (int) ($matches[1] ?? 0); + $minutes = (int) ($matches[2] ?? 0); + $seconds = (int) ($matches[3] ?? 0); + + return (float) ($hours * 3600 + $minutes * 60 + $seconds); + } + + $timestamp = strtotime($resetTime); + if (false === $timestamp) { + return null; + } + + $diff = $timestamp - time(); + + return $diff > 0 ? (float) $diff : null; + } +} diff --git a/src/platform/src/Exception/NotFoundException.php b/src/platform/src/Exception/NotFoundException.php new file mode 100644 index 000000000..7143681d3 --- /dev/null +++ b/src/platform/src/Exception/NotFoundException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +/** + * @author Junaid Farooq + */ +class NotFoundException extends InvalidArgumentException +{ +} diff --git a/src/platform/src/Exception/ServiceUnavailableException.php b/src/platform/src/Exception/ServiceUnavailableException.php new file mode 100644 index 000000000..adb37ab41 --- /dev/null +++ b/src/platform/src/Exception/ServiceUnavailableException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +/** + * @author Junaid Farooq + */ +class ServiceUnavailableException extends RuntimeException +{ +} diff --git a/src/platform/tests/Bridge/Cerebras/ResultConverterTest.php b/src/platform/tests/Bridge/Cerebras/ResultConverterTest.php index a7185a137..7bb9852aa 100644 --- a/src/platform/tests/Bridge/Cerebras/ResultConverterTest.php +++ b/src/platform/tests/Bridge/Cerebras/ResultConverterTest.php @@ -16,22 +16,143 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\AI\Platform\Bridge\Cerebras\Model; -use Symfony\AI\Platform\Bridge\Cerebras\ModelClient; use Symfony\AI\Platform\Bridge\Cerebras\ResultConverter; -use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\AI\Platform\Exception\AuthenticationException; +use Symfony\AI\Platform\Exception\NotFoundException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Exception\ServiceUnavailableException; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Junaid Farooq */ #[CoversClass(ResultConverter::class)] #[UsesClass(Model::class)] +#[UsesClass(TextResult::class)] #[Small] class ResultConverterTest extends TestCase { - public function testItSupportsTheCorrectModel() + public function testSupportsCorrectModel() { - $client = new ModelClient(new MockHttpClient(), 'csk-1234567890abcdef'); + $converter = new ResultConverter(); + $model = new Model(Model::GPT_OSS_120B); - $this->assertTrue($client->supports(new Model(Model::GPT_OSS_120B))); + $this->assertTrue($converter->supports($model)); + } + + public function testConvertSuccessfulTextResult() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'content' => 'Hello from Cerebras!', + ], + ], + ], + ]); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(TextResult::class, $result); + $this->assertSame('Hello from Cerebras!', $result->getContent()); + } + + public function testThrowsAuthenticationException() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(401); + $httpResponse->method('getContent') + ->with(false) + ->willReturn('{"error": {"message": "Invalid API key"}}'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid API key'); + + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testThrowsNotFoundException() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(404); + $httpResponse->method('getContent') + ->with(false) + ->willReturn('{"error": {"message": "Model not found"}}'); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Model not found'); + + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testThrowsServiceUnavailableException() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(503); + $httpResponse->method('getContent') + ->with(false) + ->willReturn('{"error": {"message": "Service temporarily unavailable"}}'); + + $this->expectException(ServiceUnavailableException::class); + $this->expectExceptionMessage('Service temporarily unavailable'); + + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testThrowsRuntimeExceptionWhenNoContent() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [], + ], + ], + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Response does not contain output.'); + + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testThrowsCerebrasApiError() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); + $httpResponse->method('toArray')->willReturn([ + 'type' => 'api_error', + 'message' => 'Something went wrong with the API', + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cerebras API error: "Something went wrong with the API"'); + + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testThrowsGenericRuntimeExceptionForMissingChoices() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); + $httpResponse->method('toArray')->willReturn([]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Response does not contain output.'); + + $converter->convert(new RawHttpResult($httpResponse)); } } diff --git a/src/platform/tests/Bridge/Gemini/CodeExecution/ResultConverterTest.php b/src/platform/tests/Bridge/Gemini/CodeExecution/ResultConverterTest.php index 3bfd10efd..be0d84af8 100644 --- a/src/platform/tests/Bridge/Gemini/CodeExecution/ResultConverterTest.php +++ b/src/platform/tests/Bridge/Gemini/CodeExecution/ResultConverterTest.php @@ -28,9 +28,12 @@ final class ResultConverterTest extends TestCase { public function testItReturnsAggregatedTextOnSuccess() { - $response = $this->createStub(ResponseInterface::class); + $response = $this->createMock(ResponseInterface::class); $responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/Gemini/code_execution_outcome_ok.json'); + $response + ->method('getStatusCode') + ->willReturn(200); $response ->method('toArray') ->willReturn(json_decode($responseContent, true)); @@ -45,9 +48,12 @@ public function testItReturnsAggregatedTextOnSuccess() public function testItThrowsExceptionOnFailure() { - $response = $this->createStub(ResponseInterface::class); + $response = $this->createMock(ResponseInterface::class); $responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/Gemini/code_execution_outcome_failed.json'); + $response + ->method('getStatusCode') + ->willReturn(200); $response ->method('toArray') ->willReturn(json_decode($responseContent, true)); @@ -60,9 +66,12 @@ public function testItThrowsExceptionOnFailure() public function testItThrowsExceptionOnTimeout() { - $response = $this->createStub(ResponseInterface::class); + $response = $this->createMock(ResponseInterface::class); $responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/Gemini/code_execution_outcome_deadline_exceeded.json'); + $response + ->method('getStatusCode') + ->willReturn(200); $response ->method('toArray') ->willReturn(json_decode($responseContent, true)); diff --git a/src/platform/tests/Bridge/OpenAi/Gpt/ResultConverterTest.php b/src/platform/tests/Bridge/OpenAi/Gpt/ResultConverterTest.php index 447397b10..dbf51f5a9 100644 --- a/src/platform/tests/Bridge/OpenAi/Gpt/ResultConverterTest.php +++ b/src/platform/tests/Bridge/OpenAi/Gpt/ResultConverterTest.php @@ -18,7 +18,9 @@ use Symfony\AI\Platform\Bridge\OpenAi\Gpt\ResultConverter; use Symfony\AI\Platform\Exception\AuthenticationException; use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\NotFoundException; use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Exception\ServiceUnavailableException; use Symfony\AI\Platform\Result\ChoiceResult; use Symfony\AI\Platform\Result\RawHttpResult; use Symfony\AI\Platform\Result\TextResult; @@ -39,6 +41,7 @@ public function testConvertTextResult() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->method('toArray')->willReturn([ 'choices' => [ [ @@ -61,6 +64,7 @@ public function testConvertToolCallResult() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->method('toArray')->willReturn([ 'choices' => [ [ @@ -97,6 +101,7 @@ public function testConvertMultipleChoices() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->method('toArray')->willReturn([ 'choices' => [ [ @@ -129,6 +134,7 @@ public function testContentFilterException() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->expects($this->exactly(1)) ->method('toArray') @@ -161,11 +167,9 @@ public function testThrowsAuthenticationExceptionOnInvalidApiKey() $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); $httpResponse->method('getStatusCode')->willReturn(401); - $httpResponse->method('getContent')->willReturn(json_encode([ - 'error' => [ - 'message' => 'Invalid API key provided: sk-invalid', - ], - ])); + $httpResponse->method('getContent') + ->with(false) + ->willReturn('{"error": {"message": "Invalid API key provided: sk-invalid"}}'); $this->expectException(AuthenticationException::class); $this->expectExceptionMessage('Invalid API key provided: sk-invalid'); @@ -177,6 +181,7 @@ public function testThrowsExceptionWhenNoChoices() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->method('toArray')->willReturn([]); $this->expectException(RuntimeException::class); @@ -189,6 +194,7 @@ public function testThrowsExceptionForUnsupportedFinishReason() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->method('toArray')->willReturn([ 'choices' => [ [ @@ -206,4 +212,32 @@ public function testThrowsExceptionForUnsupportedFinishReason() $converter->convert(new RawHttpResult($httpResponse)); } + + public function testThrowsNotFoundExceptionForMissingModel() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(404); + $httpResponse->method('getContent') + ->with(false) + ->willReturn('{"error": {"message": "Model gpt-5 not found"}}'); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Model gpt-5 not found'); + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testThrowsServiceUnavailableExceptionFor503() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(503); + $httpResponse->method('getContent') + ->with(false) + ->willReturn('{"error": {"message": "OpenAI servers are temporarily overloaded"}}'); + + $this->expectException(ServiceUnavailableException::class); + $this->expectExceptionMessage('OpenAI servers are temporarily overloaded'); + $converter->convert(new RawHttpResult($httpResponse)); + } } diff --git a/src/platform/tests/Bridge/Perplexity/ResultConverterTest.php b/src/platform/tests/Bridge/Perplexity/ResultConverterTest.php index 6d6bdb740..2910e0010 100644 --- a/src/platform/tests/Bridge/Perplexity/ResultConverterTest.php +++ b/src/platform/tests/Bridge/Perplexity/ResultConverterTest.php @@ -36,6 +36,7 @@ public function testConvertTextResult() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->method('toArray')->willReturn([ 'choices' => [ [ @@ -58,6 +59,7 @@ public function testConvertMultipleChoices() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->method('toArray')->willReturn([ 'choices' => [ [ @@ -90,6 +92,7 @@ public function testThrowsExceptionWhenNoChoices() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->method('toArray')->willReturn([]); $this->expectException(RuntimeException::class); @@ -102,6 +105,7 @@ public function testThrowsExceptionForUnsupportedFinishReason() { $converter = new ResultConverter(); $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); $httpResponse->method('toArray')->willReturn([ 'choices' => [ [ diff --git a/src/platform/tests/Exception/HttpErrorHandlerTest.php b/src/platform/tests/Exception/HttpErrorHandlerTest.php new file mode 100644 index 000000000..3ec53814d --- /dev/null +++ b/src/platform/tests/Exception/HttpErrorHandlerTest.php @@ -0,0 +1,191 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Exception; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Exception\AuthenticationException; +use Symfony\AI\Platform\Exception\HttpErrorHandler; +use Symfony\AI\Platform\Exception\NotFoundException; +use Symfony\AI\Platform\Exception\RateLimitExceededException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Exception\ServiceUnavailableException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(HttpErrorHandler::class)] +class HttpErrorHandlerTest extends TestCase +{ + public function testHandleHttpErrorWithSuccessfulResponse() + { + $mockResponse = new MockResponse('{"success": true}', ['http_code' => 200]); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectNotToPerformAssertions(); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleAuthenticationError() + { + $mockResponse = new MockResponse( + '{"error": {"message": "Invalid API key"}}', + ['http_code' => 401] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid API key'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleNotFoundError() + { + $mockResponse = new MockResponse( + '{"error": {"message": "Model not found"}}', + ['http_code' => 404] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Model not found'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleServiceUnavailableError() + { + $mockResponse = new MockResponse( + '{"error": {"message": "Service temporarily unavailable"}}', + ['http_code' => 503] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(ServiceUnavailableException::class); + $this->expectExceptionMessage('Service temporarily unavailable'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleGenericClientError() + { + $mockResponse = new MockResponse( + '{"error": {"message": "Bad request"}}', + ['http_code' => 400] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 400: Bad request'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithDifferentMessageFormats() + { + $mockResponse = new MockResponse( + '{"error": "Direct error message"}', + ['http_code' => 400] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 400: Direct error message'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithMessageField() + { + $mockResponse = new MockResponse( + '{"message": "Simple message format"}', + ['http_code' => 400] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 400: Simple message format'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithDetailField() + { + $mockResponse = new MockResponse( + '{"detail": "Detailed error information"}', + ['http_code' => 400] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 400: Detailed error information'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithInvalidJson() + { + $mockResponse = new MockResponse( + 'Plain text error message', + ['http_code' => 500] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 500: Plain text error message'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithEmptyResponse() + { + $mockResponse = new MockResponse('', ['http_code' => 500]); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 500: HTTP 500 error'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleRateLimitWithRetryAfterHeader() + { + $mockResponse = new MockResponse( + '{"error": "Rate limit exceeded"}', + ['http_code' => 429, 'response_headers' => ['Retry-After' => ['60']]] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + try { + HttpErrorHandler::handleHttpError($response); + $this->fail('Expected RateLimitExceededException was not thrown'); + } catch (RateLimitExceededException $e) { + $this->assertEquals(60.0, $e->getRetryAfter()); + } + } + + public function testHandleRateLimitWithoutRetryAfterHeader() + { + $mockResponse = new MockResponse( + '{"error": "Rate limit exceeded"}', + ['http_code' => 429] + ); + $client = new MockHttpClient($mockResponse); + $response = $client->request('GET', 'https://example.com'); + + $this->expectException(RateLimitExceededException::class); + $this->expectExceptionMessage('Rate limit exceeded.'); + HttpErrorHandler::handleHttpError($response); + } +}