Skip to content

Commit 1f2bda2

Browse files
committed
Merge prism-php#1008 — feat(gemini): extract rate limit violations and retry-after information
2 parents 611fb90 + f854714 commit 1f2bda2

3 files changed

Lines changed: 126 additions & 1 deletion

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Prism\Prism\Providers\Gemini\Concerns;
6+
7+
use Illuminate\Support\Carbon;
8+
use Prism\Prism\ValueObjects\ProviderRateLimit;
9+
10+
trait ProcessesRateLimits
11+
{
12+
/**
13+
* @param array<string, mixed> $responseData
14+
* @return ProviderRateLimit[]
15+
*/
16+
protected function processRateLimits(array $responseData): array
17+
{
18+
$quotaViolations = data_get($this->responseDetail($responseData, 'QuotaFailure'), 'violations', []);
19+
20+
if (! is_array($quotaViolations)) {
21+
return [];
22+
}
23+
24+
$resetsAt = $this->buildResetsAtFromResponse($responseData);
25+
26+
return collect($quotaViolations)
27+
->filter(fn (mixed $violation): bool => is_array($violation))
28+
->map(fn (array $violation): ProviderRateLimit => new ProviderRateLimit(
29+
name: (string) data_get($violation, 'quotaId', data_get($violation, 'quotaMetric', 'quota')),
30+
limit: $this->toNullableInt(data_get($violation, 'quotaValue')),
31+
remaining: null,
32+
resetsAt: $resetsAt
33+
))
34+
->all();
35+
}
36+
37+
/**
38+
* @param array<string, mixed> $responseData
39+
*/
40+
protected function extractRetryAfterSeconds(array $responseData): ?int
41+
{
42+
$retryDelay = data_get($this->responseDetail($responseData, 'RetryInfo'), 'retryDelay');
43+
44+
if (is_string($retryDelay) && preg_match('/^(?<seconds>\d+)(?:\.\d+)?s$/', $retryDelay, $matches) === 1) {
45+
return (int) $matches['seconds'];
46+
}
47+
48+
return null;
49+
}
50+
51+
/**
52+
* @param array<string, mixed> $responseData
53+
*/
54+
private function buildResetsAtFromResponse(array $responseData): ?Carbon
55+
{
56+
$retryAfter = $this->extractRetryAfterSeconds($responseData);
57+
58+
return $retryAfter === null ? null : Carbon::now()->addSeconds($retryAfter);
59+
}
60+
61+
private function toNullableInt(mixed $value): ?int
62+
{
63+
return is_int($value) || (is_string($value) && preg_match('/^\d+$/', $value) === 1)
64+
? (int) $value
65+
: null;
66+
}
67+
68+
/**
69+
* @param array<string, mixed> $responseData
70+
*/
71+
private function responseDetail(array $responseData, string $type): mixed
72+
{
73+
$details = data_get($responseData, 'error.details', []);
74+
75+
return is_array($details)
76+
? collect($details)->first(fn (mixed $detail): bool => data_get($detail, '@type') === "type.googleapis.com/google.rpc.$type")
77+
: null;
78+
}
79+
}

src/Providers/Gemini/Gemini.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Prism\Prism\Exceptions\PrismRateLimitedException;
1919
use Prism\Prism\Images\Request as ImagesRequest;
2020
use Prism\Prism\Images\Response as ImagesResponse;
21+
use Prism\Prism\Providers\Gemini\Concerns\ProcessesRateLimits;
2122
use Prism\Prism\Providers\Gemini\Handlers\Audio;
2223
use Prism\Prism\Providers\Gemini\Handlers\Cache;
2324
use Prism\Prism\Providers\Gemini\Handlers\Embeddings;
@@ -36,6 +37,7 @@
3637
class Gemini extends Provider
3738
{
3839
use InitializesClient;
40+
use ProcessesRateLimits;
3941

4042
public function __construct(
4143
#[\SensitiveParameter] public readonly string $apiKey,
@@ -110,8 +112,13 @@ public function stream(TextRequest $request): Generator
110112

111113
public function handleRequestException(string $model, RequestException $e): never
112114
{
115+
$responseData = $e->response->json() ?? [];
116+
113117
match ($e->response->getStatusCode()) {
114-
429 => throw PrismRateLimitedException::make([]),
118+
429 => throw PrismRateLimitedException::make(
119+
rateLimits: $this->processRateLimits($responseData),
120+
retryAfter: $this->extractRetryAfterSeconds($responseData)
121+
),
115122
503 => throw PrismProviderOverloadedException::make(class_basename($this)),
116123
default => $this->handleResponseErrors($e),
117124
};

tests/Providers/Gemini/ExceptionHandlingTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,45 @@ function createGeminiMockResponse(int $statusCode, array $json = []): Response
3838
->toThrow(PrismRateLimitedException::class);
3939
});
4040

41+
it('extracts retry_after and rate limit details from quota violations', function (): void {
42+
$mockResponse = createGeminiMockResponse(429, [
43+
'error' => [
44+
'code' => 429,
45+
'message' => 'You exceeded your current quota. Please retry in 35.458759309s.',
46+
'status' => 'RESOURCE_EXHAUSTED',
47+
'details' => [
48+
[
49+
'@type' => 'type.googleapis.com/google.rpc.QuotaFailure',
50+
'violations' => [
51+
[
52+
'quotaMetric' => 'generativelanguage.googleapis.com/generate_content_paid_tier_input_token_count',
53+
'quotaId' => 'GenerateContentPaidTierInputTokensPerModelPerMinute',
54+
'quotaValue' => '16000',
55+
],
56+
],
57+
],
58+
[
59+
'@type' => 'type.googleapis.com/google.rpc.RetryInfo',
60+
'retryDelay' => '35s',
61+
],
62+
],
63+
],
64+
]);
65+
$exception = new RequestException($mockResponse);
66+
67+
try {
68+
$this->provider->handleRequestException('gemini-pro', $exception);
69+
$this->fail('Expected PrismRateLimitedException to be thrown.');
70+
} catch (PrismRateLimitedException $e) {
71+
expect($e->retryAfter)->toBe(35)
72+
->and($e->rateLimits)->toHaveCount(1)
73+
->and($e->rateLimits[0]->name)->toBe('GenerateContentPaidTierInputTokensPerModelPerMinute')
74+
->and($e->rateLimits[0]->limit)->toBe(16000)
75+
->and($e->rateLimits[0]->remaining)->toBeNull()
76+
->and($e->rateLimits[0]->resetsAt)->not->toBeNull();
77+
}
78+
});
79+
4180
it('handles provider overloaded errors (503)', function (): void {
4281
$mockResponse = createGeminiMockResponse(503, []);
4382
$exception = new RequestException($mockResponse);

0 commit comments

Comments
 (0)