Skip to content

Commit 65af651

Browse files
feat(platform): Add generic exceptions
- Adds exceptions for 404 and 503 - Adds a generic http error handler
1 parent ca49b53 commit 65af651

File tree

10 files changed

+432
-20
lines changed

10 files changed

+432
-20
lines changed

src/platform/src/Bridge/Anthropic/ResultConverter.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\AI\Platform\Bridge\Anthropic;
1313

14+
use Symfony\AI\Platform\Exception\HttpErrorHandler;
1415
use Symfony\AI\Platform\Exception\RuntimeException;
1516
use Symfony\AI\Platform\Model;
1617
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -38,8 +39,11 @@ public function supports(Model $model): bool
3839

3940
public function convert(RawHttpResult|RawResultInterface $result, array $options = []): ResultInterface
4041
{
42+
$response = $result->getObject();
43+
HttpErrorHandler::handleHttpError($response);
44+
4145
if ($options['stream'] ?? false) {
42-
return new StreamResult($this->convertStream($result->getObject()));
46+
return new StreamResult($this->convertStream($response));
4347
}
4448

4549
$data = $result->getData();

src/platform/src/Bridge/Cerebras/ResultConverter.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\AI\Platform\Bridge\Cerebras;
1313

14+
use Symfony\AI\Platform\Exception\HttpErrorHandler;
1415
use Symfony\AI\Platform\Exception\RuntimeException;
1516
use Symfony\AI\Platform\Model as BaseModel;
1617
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -36,8 +37,11 @@ public function supports(BaseModel $model): bool
3637

3738
public function convert(RawHttpResult|RawResultInterface $result, array $options = []): ResultInterface
3839
{
40+
$response = $result->getObject();
41+
HttpErrorHandler::handleHttpError($response);
42+
3943
if ($options['stream'] ?? false) {
40-
return new StreamResult($this->convertStream($result->getObject()));
44+
return new StreamResult($this->convertStream($response));
4145
}
4246

4347
$data = $result->getData();

src/platform/src/Bridge/OpenAi/Gpt/ResultConverter.php

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
namespace Symfony\AI\Platform\Bridge\OpenAi\Gpt;
1313

1414
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
15-
use Symfony\AI\Platform\Exception\AuthenticationException;
1615
use Symfony\AI\Platform\Exception\ContentFilterException;
16+
use Symfony\AI\Platform\Exception\HttpErrorHandler;
1717
use Symfony\AI\Platform\Exception\RuntimeException;
1818
use Symfony\AI\Platform\Model;
1919
use Symfony\AI\Platform\Result\ChoiceResult;
@@ -44,14 +44,10 @@ public function supports(Model $model): bool
4444
public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface
4545
{
4646
$response = $result->getObject();
47-
48-
if (401 === $response->getStatusCode()) {
49-
$errorMessage = json_decode($response->getContent(false), true)['error']['message'];
50-
throw new AuthenticationException($errorMessage);
51-
}
47+
HttpErrorHandler::handleHttpError($response);
5248

5349
if ($options['stream'] ?? false) {
54-
return new StreamResult($this->convertStream($result->getObject()));
50+
return new StreamResult($this->convertStream($response));
5551
}
5652

5753
$data = $result->getData();

src/platform/src/Bridge/Perplexity/ResultConverter.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\AI\Platform\Bridge\Perplexity;
1313

14+
use Symfony\AI\Platform\Exception\HttpErrorHandler;
1415
use Symfony\AI\Platform\Exception\RuntimeException;
1516
use Symfony\AI\Platform\Metadata\Metadata;
1617
use Symfony\AI\Platform\Model;
@@ -38,8 +39,11 @@ public function supports(Model $model): bool
3839

3940
public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface
4041
{
42+
$response = $result->getObject();
43+
HttpErrorHandler::handleHttpError($response);
44+
4145
if ($options['stream'] ?? false) {
42-
return new StreamResult($this->convertStream($result->getObject()));
46+
return new StreamResult($this->convertStream($response));
4347
}
4448

4549
$data = $result->getData();
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Exception;
13+
14+
use Symfony\Contracts\HttpClient\ResponseInterface;
15+
16+
/**
17+
* @author Junaid Farooq <[email protected]>
18+
*/
19+
final class HttpErrorHandler
20+
{
21+
public static function handleHttpError(ResponseInterface $response): void
22+
{
23+
$statusCode = $response->getStatusCode();
24+
25+
if ($statusCode >= 200 && $statusCode < 300) {
26+
return;
27+
}
28+
29+
$errorMessage = self::extractErrorMessage($response);
30+
31+
match ($statusCode) {
32+
401 => throw new AuthenticationException($errorMessage),
33+
404 => throw new NotFoundException($errorMessage),
34+
503 => throw new ServiceUnavailableException($errorMessage),
35+
default => throw new RuntimeException(\sprintf('HTTP %d: %s', $statusCode, $errorMessage)),
36+
};
37+
}
38+
39+
private static function extractErrorMessage(ResponseInterface $response): string
40+
{
41+
try {
42+
$content = $response->getContent(false);
43+
44+
if ('' === $content) {
45+
return \sprintf('HTTP %d error', $response->getStatusCode());
46+
}
47+
48+
$data = json_decode($content, true);
49+
50+
if (null === $data || !\is_array($data)) {
51+
return \sprintf('HTTP %d error', $response->getStatusCode());
52+
}
53+
54+
if (isset($data['error']['message'])) {
55+
return $data['error']['message'];
56+
}
57+
58+
if (isset($data['detail'])) {
59+
return $data['detail'];
60+
}
61+
62+
return $content;
63+
} catch (\Throwable) {
64+
try {
65+
$content = $response->getContent(false);
66+
67+
return !empty($content) ? $content : \sprintf('HTTP %d error', $response->getStatusCode());
68+
} catch (\Throwable) {
69+
return \sprintf('HTTP %d error', $response->getStatusCode());
70+
}
71+
}
72+
}
73+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Exception;
13+
14+
/**
15+
* @author Junaid Farooq <[email protected]>
16+
*/
17+
class NotFoundException extends InvalidArgumentException
18+
{
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Exception;
13+
14+
/**
15+
* @author Junaid Farooq <[email protected]>
16+
*/
17+
class ServiceUnavailableException extends RuntimeException
18+
{
19+
}

src/platform/tests/Bridge/Cerebras/ResultConverterTest.php

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,143 @@
1616
use PHPUnit\Framework\Attributes\UsesClass;
1717
use PHPUnit\Framework\TestCase;
1818
use Symfony\AI\Platform\Bridge\Cerebras\Model;
19-
use Symfony\AI\Platform\Bridge\Cerebras\ModelClient;
2019
use Symfony\AI\Platform\Bridge\Cerebras\ResultConverter;
21-
use Symfony\Component\HttpClient\MockHttpClient;
20+
use Symfony\AI\Platform\Exception\AuthenticationException;
21+
use Symfony\AI\Platform\Exception\NotFoundException;
22+
use Symfony\AI\Platform\Exception\RuntimeException;
23+
use Symfony\AI\Platform\Exception\ServiceUnavailableException;
24+
use Symfony\AI\Platform\Result\RawHttpResult;
25+
use Symfony\AI\Platform\Result\TextResult;
26+
use Symfony\Contracts\HttpClient\ResponseInterface;
2227

2328
/**
2429
* @author Junaid Farooq <[email protected]>
2530
*/
2631
#[CoversClass(ResultConverter::class)]
2732
#[UsesClass(Model::class)]
33+
#[UsesClass(TextResult::class)]
2834
#[Small]
2935
class ResultConverterTest extends TestCase
3036
{
31-
public function testItSupportsTheCorrectModel()
37+
public function testSupportsCorrectModel()
3238
{
33-
$client = new ModelClient(new MockHttpClient(), 'csk-1234567890abcdef');
39+
$converter = new ResultConverter();
40+
$model = new Model(Model::GPT_OSS_120B);
3441

35-
$this->assertTrue($client->supports(new Model(Model::GPT_OSS_120B)));
42+
$this->assertTrue($converter->supports($model));
43+
}
44+
45+
public function testConvertSuccessfulTextResult()
46+
{
47+
$converter = new ResultConverter();
48+
$httpResponse = self::createMock(ResponseInterface::class);
49+
$httpResponse->method('getStatusCode')->willReturn(200);
50+
$httpResponse->method('toArray')->willReturn([
51+
'choices' => [
52+
[
53+
'message' => [
54+
'content' => 'Hello from Cerebras!',
55+
],
56+
],
57+
],
58+
]);
59+
60+
$result = $converter->convert(new RawHttpResult($httpResponse));
61+
62+
$this->assertInstanceOf(TextResult::class, $result);
63+
$this->assertSame('Hello from Cerebras!', $result->getContent());
64+
}
65+
66+
public function testThrowsAuthenticationException()
67+
{
68+
$converter = new ResultConverter();
69+
$httpResponse = self::createMock(ResponseInterface::class);
70+
$httpResponse->method('getStatusCode')->willReturn(401);
71+
$httpResponse->method('getContent')
72+
->with(false)
73+
->willReturn('{"error": {"message": "Invalid API key"}}');
74+
75+
$this->expectException(AuthenticationException::class);
76+
$this->expectExceptionMessage('Invalid API key');
77+
78+
$converter->convert(new RawHttpResult($httpResponse));
79+
}
80+
81+
public function testThrowsNotFoundException()
82+
{
83+
$converter = new ResultConverter();
84+
$httpResponse = self::createMock(ResponseInterface::class);
85+
$httpResponse->method('getStatusCode')->willReturn(404);
86+
$httpResponse->method('getContent')
87+
->with(false)
88+
->willReturn('{"error": {"message": "Model not found"}}');
89+
90+
$this->expectException(NotFoundException::class);
91+
$this->expectExceptionMessage('Model not found');
92+
93+
$converter->convert(new RawHttpResult($httpResponse));
94+
}
95+
96+
public function testThrowsServiceUnavailableException()
97+
{
98+
$converter = new ResultConverter();
99+
$httpResponse = self::createMock(ResponseInterface::class);
100+
$httpResponse->method('getStatusCode')->willReturn(503);
101+
$httpResponse->method('getContent')
102+
->with(false)
103+
->willReturn('{"error": {"message": "Service temporarily unavailable"}}');
104+
105+
$this->expectException(ServiceUnavailableException::class);
106+
$this->expectExceptionMessage('Service temporarily unavailable');
107+
108+
$converter->convert(new RawHttpResult($httpResponse));
109+
}
110+
111+
public function testThrowsRuntimeExceptionWhenNoContent()
112+
{
113+
$converter = new ResultConverter();
114+
$httpResponse = self::createMock(ResponseInterface::class);
115+
$httpResponse->method('getStatusCode')->willReturn(200);
116+
$httpResponse->method('toArray')->willReturn([
117+
'choices' => [
118+
[
119+
'message' => [],
120+
],
121+
],
122+
]);
123+
124+
$this->expectException(RuntimeException::class);
125+
$this->expectExceptionMessage('Response does not contain output.');
126+
127+
$converter->convert(new RawHttpResult($httpResponse));
128+
}
129+
130+
public function testThrowsCerebrasApiError()
131+
{
132+
$converter = new ResultConverter();
133+
$httpResponse = self::createMock(ResponseInterface::class);
134+
$httpResponse->method('getStatusCode')->willReturn(200);
135+
$httpResponse->method('toArray')->willReturn([
136+
'type' => 'api_error',
137+
'message' => 'Something went wrong with the API',
138+
]);
139+
140+
$this->expectException(RuntimeException::class);
141+
$this->expectExceptionMessage('Cerebras API error: "Something went wrong with the API"');
142+
143+
$converter->convert(new RawHttpResult($httpResponse));
144+
}
145+
146+
public function testThrowsGenericRuntimeExceptionForMissingChoices()
147+
{
148+
$converter = new ResultConverter();
149+
$httpResponse = self::createMock(ResponseInterface::class);
150+
$httpResponse->method('getStatusCode')->willReturn(200);
151+
$httpResponse->method('toArray')->willReturn([]);
152+
153+
$this->expectException(RuntimeException::class);
154+
$this->expectExceptionMessage('Response does not contain output.');
155+
156+
$converter->convert(new RawHttpResult($httpResponse));
36157
}
37158
}

0 commit comments

Comments
 (0)