From e1451fe455afa51f93792b8229128e3f62bd14b9 Mon Sep 17 00:00:00 2001 From: junaid farooq Date: Sat, 13 Sep 2025 01:45:54 +0530 Subject: [PATCH 1/3] feat(platform): Add generic exceptions - Adds exceptions for 404 and 503 - Adds a generic http error handler --- .../src/Bridge/Anthropic/ResultConverter.php | 3 + .../src/Bridge/Cerebras/ResultConverter.php | 6 +- .../src/Bridge/OpenAi/Gpt/ResultConverter.php | 8 +- .../src/Bridge/Perplexity/ResultConverter.php | 6 +- .../src/Exception/HttpErrorHandler.php | 73 +++++++++ .../src/Exception/NotFoundException.php | 19 +++ .../Exception/ServiceUnavailableException.php | 19 +++ .../Bridge/Cerebras/ResultConverterTest.php | 131 ++++++++++++++++- .../Bridge/OpenAi/Gpt/ResultConverterTest.php | 44 +++++- .../tests/Exception/HttpErrorHandlerTest.php | 138 ++++++++++++++++++ 10 files changed, 429 insertions(+), 18 deletions(-) create mode 100644 src/platform/src/Exception/HttpErrorHandler.php create mode 100644 src/platform/src/Exception/NotFoundException.php create mode 100644 src/platform/src/Exception/ServiceUnavailableException.php create mode 100644 src/platform/tests/Exception/HttpErrorHandlerTest.php diff --git a/src/platform/src/Bridge/Anthropic/ResultConverter.php b/src/platform/src/Bridge/Anthropic/ResultConverter.php index 6916aa256..faf601f60 100644 --- a/src/platform/src/Bridge/Anthropic/ResultConverter.php +++ b/src/platform/src/Bridge/Anthropic/ResultConverter.php @@ -11,6 +11,7 @@ namespace Symfony\AI\Platform\Bridge\Anthropic; +use Symfony\AI\Platform\Exception\HttpErrorHandler; use Symfony\AI\Platform\Exception\RateLimitExceededException; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; @@ -47,6 +48,8 @@ public function convert(RawHttpResult|RawResultInterface $result, array $options 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/OpenAi/Gpt/ResultConverter.php b/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php index 43592cb27..c824b736d 100644 --- a/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php +++ b/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php @@ -12,8 +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\HttpErrorHandler; use Symfony\AI\Platform\Exception\RateLimitExceededException; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; @@ -45,11 +45,7 @@ public function supports(Model $model): bool public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface { $response = $result->getObject(); - - if (401 === $response->getStatusCode()) { - $errorMessage = json_decode($response->getContent(false), true)['error']['message']; - throw new AuthenticationException($errorMessage); - } + HttpErrorHandler::handleHttpError($response); if (429 === $response->getStatusCode()) { $headers = $response->getHeaders(false); 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..eec71f06f --- /dev/null +++ b/src/platform/src/Exception/HttpErrorHandler.php @@ -0,0 +1,73 @@ + + * + * 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), + 503 => throw new ServiceUnavailableException($errorMessage), + default => throw new RuntimeException(\sprintf('HTTP %d: %s', $statusCode, $errorMessage)), + }; + } + + private static function extractErrorMessage(ResponseInterface $response): string + { + try { + $content = $response->getContent(false); + + if ('' === $content) { + return \sprintf('HTTP %d error', $response->getStatusCode()); + } + + $data = json_decode($content, true); + + if (null === $data || !\is_array($data)) { + return \sprintf('HTTP %d error', $response->getStatusCode()); + } + + if (isset($data['error']['message'])) { + return $data['error']['message']; + } + + if (isset($data['detail'])) { + return $data['detail']; + } + + return $content; + } catch (\Throwable) { + try { + $content = $response->getContent(false); + + return !empty($content) ? $content : \sprintf('HTTP %d error', $response->getStatusCode()); + } catch (\Throwable) { + return \sprintf('HTTP %d error', $response->getStatusCode()); + } + } + } +} 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/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/Exception/HttpErrorHandlerTest.php b/src/platform/tests/Exception/HttpErrorHandlerTest.php new file mode 100644 index 000000000..700e51410 --- /dev/null +++ b/src/platform/tests/Exception/HttpErrorHandlerTest.php @@ -0,0 +1,138 @@ + + * + * 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\RuntimeException; +use Symfony\AI\Platform\Exception\ServiceUnavailableException; +use Symfony\Component\HttpClient\Response\MockResponse; + +#[CoversClass(HttpErrorHandler::class)] +class HttpErrorHandlerTest extends TestCase +{ + public function testHandleHttpErrorWithSuccessfulResponse(): + { + $response = new MockResponse('{"success": true}', ['http_code' => 200]); + + $this->expectNotToPerformAssertions(); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleAuthenticationError(): + { + $response = new MockResponse( + '{"error": {"message": "Invalid API key"}}', + ['http_code' => 401] + ); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid API key'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleNotFoundError(): + { + $response = new MockResponse( + '{"error": {"message": "Model not found"}}', + ['http_code' => 404] + ); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Model not found'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleServiceUnavailableError(): + { + $response = new MockResponse( + '{"error": {"message": "Service temporarily unavailable"}}', + ['http_code' => 503] + ); + + $this->expectException(ServiceUnavailableException::class); + $this->expectExceptionMessage('Service temporarily unavailable'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleGenericClientError(): + { + $response = new MockResponse( + '{"error": {"message": "Bad request"}}', + ['http_code' => 400] + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 400: Bad request'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithDifferentMessageFormats(): + { + $response = new MockResponse( + '{"error": "Direct error message"}', + ['http_code' => 400] + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 400: Direct error message'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithMessageField(): + { + $response = new MockResponse( + '{"message": "Simple message format"}', + ['http_code' => 400] + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 400: Simple message format'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithDetailField(): + { + $response = new MockResponse( + '{"detail": "Detailed error information"}', + ['http_code' => 400] + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 400: Detailed error information'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithInvalidJson(): + { + $response = new MockResponse( + 'Plain text error message', + ['http_code' => 500] + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 500: Plain text error message'); + HttpErrorHandler::handleHttpError($response); + } + + public function testHandleErrorWithEmptyResponse(): + { + $response = new MockResponse('', ['http_code' => 500]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('HTTP 500: HTTP 500 error'); + HttpErrorHandler::handleHttpError($response); + } +} From fbe72c293bb23f453e3c2fc169d313e7ca356be2 Mon Sep 17 00:00:00 2001 From: junaid farooq Date: Mon, 15 Sep 2025 12:48:26 +0530 Subject: [PATCH 2/3] fix(platform): Add generic exceptions - Fixes tests --- .../src/Exception/HttpErrorHandler.php | 40 +++++------- .../tests/Exception/HttpErrorHandlerTest.php | 61 +++++++++++++------ 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/src/platform/src/Exception/HttpErrorHandler.php b/src/platform/src/Exception/HttpErrorHandler.php index eec71f06f..cf98d1153 100644 --- a/src/platform/src/Exception/HttpErrorHandler.php +++ b/src/platform/src/Exception/HttpErrorHandler.php @@ -38,36 +38,26 @@ public static function handleHttpError(ResponseInterface $response): void private static function extractErrorMessage(ResponseInterface $response): string { - try { - $content = $response->getContent(false); + $content = $response->getContent(false); - if ('' === $content) { - return \sprintf('HTTP %d error', $response->getStatusCode()); - } - - $data = json_decode($content, true); - - if (null === $data || !\is_array($data)) { - return \sprintf('HTTP %d error', $response->getStatusCode()); - } - - if (isset($data['error']['message'])) { - return $data['error']['message']; - } + if ('' === $content) { + return \sprintf('HTTP %d error', $response->getStatusCode()); + } - if (isset($data['detail'])) { - return $data['detail']; - } + $data = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + if (!\is_array($data)) { return $content; - } catch (\Throwable) { - try { - $content = $response->getContent(false); + } + + if (isset($data['error']['message'])) { + return $data['error']['message']; + } - return !empty($content) ? $content : \sprintf('HTTP %d error', $response->getStatusCode()); - } catch (\Throwable) { - return \sprintf('HTTP %d error', $response->getStatusCode()); - } + if (isset($data['error']) && \is_string($data['error'])) { + return $data['error']; } + + return $data['message'] ?? $data['detail'] ?? $content; } } diff --git a/src/platform/tests/Exception/HttpErrorHandlerTest.php b/src/platform/tests/Exception/HttpErrorHandlerTest.php index 700e51410..c23f61c1b 100644 --- a/src/platform/tests/Exception/HttpErrorHandlerTest.php +++ b/src/platform/tests/Exception/HttpErrorHandlerTest.php @@ -18,118 +18,139 @@ use Symfony\AI\Platform\Exception\NotFoundException; 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(): + public function testHandleHttpErrorWithSuccessfulResponse() { - $response = new MockResponse('{"success": true}', ['http_code' => 200]); + $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(): + public function testHandleAuthenticationError() { - $response = new MockResponse( + $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(): + public function testHandleNotFoundError() { - $response = new MockResponse( + $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(): + public function testHandleServiceUnavailableError() { - $response = new MockResponse( + $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(): + public function testHandleGenericClientError() { - $response = new MockResponse( + $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(): + public function testHandleErrorWithDifferentMessageFormats() { - $response = new MockResponse( + $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(): + public function testHandleErrorWithMessageField() { - $response = new MockResponse( + $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(): + public function testHandleErrorWithDetailField() { - $response = new MockResponse( + $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(): + public function testHandleErrorWithInvalidJson() { - $response = new MockResponse( + $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(): + public function testHandleErrorWithEmptyResponse() { - $response = new MockResponse('', ['http_code' => 500]); + $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'); From d5f9325ece656144975e4fbc7a890e7907ec245a Mon Sep 17 00:00:00 2001 From: junaid farooq Date: Mon, 15 Sep 2025 13:39:40 +0530 Subject: [PATCH 3/3] feat(platform): Add generic exceptions - Adds logic, to handle rate limit exception, to the handler --- .../src/Bridge/Anthropic/ResultConverter.php | 7 --- .../Bridge/Gemini/Gemini/ResultConverter.php | 6 +-- .../src/Bridge/OpenAi/Gpt/ResultConverter.php | 36 +-------------- .../src/Exception/HttpErrorHandler.php | 46 ++++++++++++++++++- .../CodeExecution/ResultConverterTest.php | 15 ++++-- .../Bridge/Perplexity/ResultConverterTest.php | 4 ++ .../tests/Exception/HttpErrorHandlerTest.php | 32 +++++++++++++ 7 files changed, 96 insertions(+), 50 deletions(-) diff --git a/src/platform/src/Bridge/Anthropic/ResultConverter.php b/src/platform/src/Bridge/Anthropic/ResultConverter.php index faf601f60..6d633df48 100644 --- a/src/platform/src/Bridge/Anthropic/ResultConverter.php +++ b/src/platform/src/Bridge/Anthropic/ResultConverter.php @@ -12,7 +12,6 @@ namespace Symfony\AI\Platform\Bridge\Anthropic; use Symfony\AI\Platform\Exception\HttpErrorHandler; -use Symfony\AI\Platform\Exception\RateLimitExceededException; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Result\RawHttpResult; @@ -42,12 +41,6 @@ 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) { 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 c824b736d..86461204b 100644 --- a/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php +++ b/src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php @@ -14,7 +14,6 @@ use Symfony\AI\Platform\Bridge\OpenAi\Gpt; use Symfony\AI\Platform\Exception\ContentFilterException; use Symfony\AI\Platform\Exception\HttpErrorHandler; -use Symfony\AI\Platform\Exception\RateLimitExceededException; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Result\ChoiceResult; @@ -45,20 +44,8 @@ public function supports(Model $model): bool public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface { $response = $result->getObject(); - HttpErrorHandler::handleHttpError($response); - - 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)); @@ -204,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/Exception/HttpErrorHandler.php b/src/platform/src/Exception/HttpErrorHandler.php index cf98d1153..0c5f0849b 100644 --- a/src/platform/src/Exception/HttpErrorHandler.php +++ b/src/platform/src/Exception/HttpErrorHandler.php @@ -31,6 +31,7 @@ public static function handleHttpError(ResponseInterface $response): void 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)), }; @@ -44,7 +45,7 @@ private static function extractErrorMessage(ResponseInterface $response): string return \sprintf('HTTP %d error', $response->getStatusCode()); } - $data = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + $data = json_decode($content, true); if (!\is_array($data)) { return $content; @@ -60,4 +61,47 @@ private static function extractErrorMessage(ResponseInterface $response): string 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/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/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 index c23f61c1b..3ec53814d 100644 --- a/src/platform/tests/Exception/HttpErrorHandlerTest.php +++ b/src/platform/tests/Exception/HttpErrorHandlerTest.php @@ -16,6 +16,7 @@ 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; @@ -156,4 +157,35 @@ public function testHandleErrorWithEmptyResponse() $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); + } }