Skip to content

Commit d5f9325

Browse files
feat(platform): Add generic exceptions
- Adds logic, to handle rate limit exception, to the handler
1 parent fbe72c2 commit d5f9325

File tree

7 files changed

+96
-50
lines changed

7 files changed

+96
-50
lines changed

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

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
namespace Symfony\AI\Platform\Bridge\Anthropic;
1313

1414
use Symfony\AI\Platform\Exception\HttpErrorHandler;
15-
use Symfony\AI\Platform\Exception\RateLimitExceededException;
1615
use Symfony\AI\Platform\Exception\RuntimeException;
1716
use Symfony\AI\Platform\Model;
1817
use Symfony\AI\Platform\Result\RawHttpResult;
@@ -42,12 +41,6 @@ public function convert(RawHttpResult|RawResultInterface $result, array $options
4241
{
4342
$response = $result->getObject();
4443

45-
if (429 === $response->getStatusCode()) {
46-
$retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
47-
$retryAfterValue = $retryAfter ? (float) $retryAfter : null;
48-
throw new RateLimitExceededException($retryAfterValue);
49-
}
50-
5144
HttpErrorHandler::handleHttpError($response);
5245

5346
if ($options['stream'] ?? false) {

src/platform/src/Bridge/Gemini/Gemini/ResultConverter.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
namespace Symfony\AI\Platform\Bridge\Gemini\Gemini;
1313

1414
use Symfony\AI\Platform\Bridge\Gemini\Gemini;
15-
use Symfony\AI\Platform\Exception\RateLimitExceededException;
15+
use Symfony\AI\Platform\Exception\HttpErrorHandler;
1616
use Symfony\AI\Platform\Exception\RuntimeException;
1717
use Symfony\AI\Platform\Model;
1818
use Symfony\AI\Platform\Result\ChoiceResult;
@@ -45,9 +45,7 @@ public function convert(RawResultInterface|RawHttpResult $result, array $options
4545
{
4646
$response = $result->getObject();
4747

48-
if (429 === $response->getStatusCode()) {
49-
throw new RateLimitExceededException();
50-
}
48+
HttpErrorHandler::handleHttpError($response);
5149

5250
if ($options['stream'] ?? false) {
5351
return new StreamResult($this->convertStream($response));

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

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
use Symfony\AI\Platform\Bridge\OpenAi\Gpt;
1515
use Symfony\AI\Platform\Exception\ContentFilterException;
1616
use Symfony\AI\Platform\Exception\HttpErrorHandler;
17-
use Symfony\AI\Platform\Exception\RateLimitExceededException;
1817
use Symfony\AI\Platform\Exception\RuntimeException;
1918
use Symfony\AI\Platform\Model;
2019
use Symfony\AI\Platform\Result\ChoiceResult;
@@ -45,20 +44,8 @@ public function supports(Model $model): bool
4544
public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface
4645
{
4746
$response = $result->getObject();
48-
HttpErrorHandler::handleHttpError($response);
49-
50-
if (429 === $response->getStatusCode()) {
51-
$headers = $response->getHeaders(false);
52-
$resetTime = null;
53-
54-
if (isset($headers['x-ratelimit-reset-requests'][0])) {
55-
$resetTime = self::parseResetTime($headers['x-ratelimit-reset-requests'][0]);
56-
} elseif (isset($headers['x-ratelimit-reset-tokens'][0])) {
57-
$resetTime = self::parseResetTime($headers['x-ratelimit-reset-tokens'][0]);
58-
}
5947

60-
throw new RateLimitExceededException($resetTime);
61-
}
48+
HttpErrorHandler::handleHttpError($response);
6249

6350
if ($options['stream'] ?? false) {
6451
return new StreamResult($this->convertStream($response));
@@ -204,25 +191,4 @@ private function convertToolCall(array $toolCall): ToolCall
204191

205192
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
206193
}
207-
208-
/**
209-
* Converts OpenAI's reset time format (e.g. "1s", "6m0s", "2m30s") into seconds.
210-
*
211-
* Supported formats:
212-
* - "1s"
213-
* - "6m0s"
214-
* - "2m30s"
215-
*/
216-
private static function parseResetTime(string $resetTime): float
217-
{
218-
$seconds = 0;
219-
220-
if (preg_match('/^(?:(\d+)m)?(?:(\d+)s)?$/', $resetTime, $matches)) {
221-
$minutes = isset($matches[1]) ? (int) $matches[1] : 0;
222-
$secs = isset($matches[2]) ? (int) $matches[2] : 0;
223-
$seconds = ($minutes * 60) + $secs;
224-
}
225-
226-
return (float) $seconds;
227-
}
228194
}

src/platform/src/Exception/HttpErrorHandler.php

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public static function handleHttpError(ResponseInterface $response): void
3131
match ($statusCode) {
3232
401 => throw new AuthenticationException($errorMessage),
3333
404 => throw new NotFoundException($errorMessage),
34+
429 => throw new RateLimitExceededException(self::extractRetryAfter($response)),
3435
503 => throw new ServiceUnavailableException($errorMessage),
3536
default => throw new RuntimeException(\sprintf('HTTP %d: %s', $statusCode, $errorMessage)),
3637
};
@@ -44,7 +45,7 @@ private static function extractErrorMessage(ResponseInterface $response): string
4445
return \sprintf('HTTP %d error', $response->getStatusCode());
4546
}
4647

47-
$data = json_decode($content, true, 512, \JSON_THROW_ON_ERROR);
48+
$data = json_decode($content, true);
4849

4950
if (!\is_array($data)) {
5051
return $content;
@@ -60,4 +61,47 @@ private static function extractErrorMessage(ResponseInterface $response): string
6061

6162
return $data['message'] ?? $data['detail'] ?? $content;
6263
}
64+
65+
private static function extractRetryAfter(ResponseInterface $response): ?float
66+
{
67+
$headers = $response->getHeaders(false);
68+
69+
if (isset($headers['retry-after'][0])) {
70+
return (float) $headers['retry-after'][0];
71+
}
72+
73+
if (isset($headers['x-ratelimit-reset-requests'][0])) {
74+
return self::parseResetTime($headers['x-ratelimit-reset-requests'][0]);
75+
}
76+
77+
if (isset($headers['x-ratelimit-reset-tokens'][0])) {
78+
return self::parseResetTime($headers['x-ratelimit-reset-tokens'][0]);
79+
}
80+
81+
return null;
82+
}
83+
84+
private static function parseResetTime(string $resetTime): ?float
85+
{
86+
if (is_numeric($resetTime)) {
87+
return (float) $resetTime;
88+
}
89+
90+
if (preg_match('/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/', $resetTime, $matches)) {
91+
$hours = (int) ($matches[1] ?? 0);
92+
$minutes = (int) ($matches[2] ?? 0);
93+
$seconds = (int) ($matches[3] ?? 0);
94+
95+
return (float) ($hours * 3600 + $minutes * 60 + $seconds);
96+
}
97+
98+
$timestamp = strtotime($resetTime);
99+
if (false === $timestamp) {
100+
return null;
101+
}
102+
103+
$diff = $timestamp - time();
104+
105+
return $diff > 0 ? (float) $diff : null;
106+
}
63107
}

src/platform/tests/Bridge/Gemini/CodeExecution/ResultConverterTest.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ final class ResultConverterTest extends TestCase
2828
{
2929
public function testItReturnsAggregatedTextOnSuccess()
3030
{
31-
$response = $this->createStub(ResponseInterface::class);
31+
$response = $this->createMock(ResponseInterface::class);
3232
$responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/Gemini/code_execution_outcome_ok.json');
3333

34+
$response
35+
->method('getStatusCode')
36+
->willReturn(200);
3437
$response
3538
->method('toArray')
3639
->willReturn(json_decode($responseContent, true));
@@ -45,9 +48,12 @@ public function testItReturnsAggregatedTextOnSuccess()
4548

4649
public function testItThrowsExceptionOnFailure()
4750
{
48-
$response = $this->createStub(ResponseInterface::class);
51+
$response = $this->createMock(ResponseInterface::class);
4952
$responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/Gemini/code_execution_outcome_failed.json');
5053

54+
$response
55+
->method('getStatusCode')
56+
->willReturn(200);
5157
$response
5258
->method('toArray')
5359
->willReturn(json_decode($responseContent, true));
@@ -60,9 +66,12 @@ public function testItThrowsExceptionOnFailure()
6066

6167
public function testItThrowsExceptionOnTimeout()
6268
{
63-
$response = $this->createStub(ResponseInterface::class);
69+
$response = $this->createMock(ResponseInterface::class);
6470
$responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/Gemini/code_execution_outcome_deadline_exceeded.json');
6571

72+
$response
73+
->method('getStatusCode')
74+
->willReturn(200);
6675
$response
6776
->method('toArray')
6877
->willReturn(json_decode($responseContent, true));

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public function testConvertTextResult()
3636
{
3737
$converter = new ResultConverter();
3838
$httpResponse = self::createMock(ResponseInterface::class);
39+
$httpResponse->method('getStatusCode')->willReturn(200);
3940
$httpResponse->method('toArray')->willReturn([
4041
'choices' => [
4142
[
@@ -58,6 +59,7 @@ public function testConvertMultipleChoices()
5859
{
5960
$converter = new ResultConverter();
6061
$httpResponse = self::createMock(ResponseInterface::class);
62+
$httpResponse->method('getStatusCode')->willReturn(200);
6163
$httpResponse->method('toArray')->willReturn([
6264
'choices' => [
6365
[
@@ -90,6 +92,7 @@ public function testThrowsExceptionWhenNoChoices()
9092
{
9193
$converter = new ResultConverter();
9294
$httpResponse = self::createMock(ResponseInterface::class);
95+
$httpResponse->method('getStatusCode')->willReturn(200);
9396
$httpResponse->method('toArray')->willReturn([]);
9497

9598
$this->expectException(RuntimeException::class);
@@ -102,6 +105,7 @@ public function testThrowsExceptionForUnsupportedFinishReason()
102105
{
103106
$converter = new ResultConverter();
104107
$httpResponse = self::createMock(ResponseInterface::class);
108+
$httpResponse->method('getStatusCode')->willReturn(200);
105109
$httpResponse->method('toArray')->willReturn([
106110
'choices' => [
107111
[

src/platform/tests/Exception/HttpErrorHandlerTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\AI\Platform\Exception\AuthenticationException;
1717
use Symfony\AI\Platform\Exception\HttpErrorHandler;
1818
use Symfony\AI\Platform\Exception\NotFoundException;
19+
use Symfony\AI\Platform\Exception\RateLimitExceededException;
1920
use Symfony\AI\Platform\Exception\RuntimeException;
2021
use Symfony\AI\Platform\Exception\ServiceUnavailableException;
2122
use Symfony\Component\HttpClient\MockHttpClient;
@@ -156,4 +157,35 @@ public function testHandleErrorWithEmptyResponse()
156157
$this->expectExceptionMessage('HTTP 500: HTTP 500 error');
157158
HttpErrorHandler::handleHttpError($response);
158159
}
160+
161+
public function testHandleRateLimitWithRetryAfterHeader()
162+
{
163+
$mockResponse = new MockResponse(
164+
'{"error": "Rate limit exceeded"}',
165+
['http_code' => 429, 'response_headers' => ['Retry-After' => ['60']]]
166+
);
167+
$client = new MockHttpClient($mockResponse);
168+
$response = $client->request('GET', 'https://example.com');
169+
170+
try {
171+
HttpErrorHandler::handleHttpError($response);
172+
$this->fail('Expected RateLimitExceededException was not thrown');
173+
} catch (RateLimitExceededException $e) {
174+
$this->assertEquals(60.0, $e->getRetryAfter());
175+
}
176+
}
177+
178+
public function testHandleRateLimitWithoutRetryAfterHeader()
179+
{
180+
$mockResponse = new MockResponse(
181+
'{"error": "Rate limit exceeded"}',
182+
['http_code' => 429]
183+
);
184+
$client = new MockHttpClient($mockResponse);
185+
$response = $client->request('GET', 'https://example.com');
186+
187+
$this->expectException(RateLimitExceededException::class);
188+
$this->expectExceptionMessage('Rate limit exceeded.');
189+
HttpErrorHandler::handleHttpError($response);
190+
}
159191
}

0 commit comments

Comments
 (0)