From 638c9b06d0886384f05cac22991f52e823036eea Mon Sep 17 00:00:00 2001 From: cutlope <34349994+cutlope@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:55:47 +0500 Subject: [PATCH 1/2] feat(gemini): implement rate limit processing and retry handling - Added a new trait `ProcessRateLimits` to handle rate limit details and retry logic. - Integrated rate limit processing into the `handleRequestException` method for 429 errors. - Enhanced exception handling tests to verify extraction of rate limit details and retry information. --- .../Gemini/Concerns/ProcessRateLimits.php | 110 ++++++++++++++++++ src/Providers/Gemini/Gemini.php | 9 +- .../Gemini/ExceptionHandlingTest.php | 39 +++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/Providers/Gemini/Concerns/ProcessRateLimits.php diff --git a/src/Providers/Gemini/Concerns/ProcessRateLimits.php b/src/Providers/Gemini/Concerns/ProcessRateLimits.php new file mode 100644 index 000000000..352b106bf --- /dev/null +++ b/src/Providers/Gemini/Concerns/ProcessRateLimits.php @@ -0,0 +1,110 @@ + $responseData + * @return ProviderRateLimit[] + */ + protected function processRateLimits(array $responseData): array + { + $violations = data_get($responseData, 'error.details', []); + + if (! is_array($violations)) { + return []; + } + + $quotaFailure = collect($violations) + ->first(fn (mixed $detail): bool => data_get($detail, '@type') === 'type.googleapis.com/google.rpc.QuotaFailure'); + + $quotaViolations = data_get($quotaFailure, 'violations', []); + + if (! is_array($quotaViolations)) { + return []; + } + + return collect($quotaViolations) + ->filter(fn (mixed $violation): bool => is_array($violation)) + ->map(fn (array $violation): ProviderRateLimit => new ProviderRateLimit( + name: (string) data_get($violation, 'quotaId', data_get($violation, 'quotaMetric', 'quota')), + limit: $this->toNullableInt(data_get($violation, 'quotaValue')), + remaining: null, + resetsAt: $this->buildResetsAtFromResponse($responseData) + )) + ->all(); + } + + /** + * @param array $responseData + */ + protected function extractRetryAfterSeconds(array $responseData): ?int + { + $details = data_get($responseData, 'error.details', []); + + if (! is_array($details)) { + return null; + } + + $retryInfo = collect($details) + ->first(fn (mixed $detail): bool => data_get($detail, '@type') === 'type.googleapis.com/google.rpc.RetryInfo'); + + $retryDelay = data_get($retryInfo, 'retryDelay'); + + $retryAfterFromMessage = $this->extractRetryAfterFromMessage( + data_get($responseData, 'error.message') + ); + + if (is_string($retryDelay) && preg_match('/^(?\d+)(?:\.\d+)?s$/', $retryDelay, $matches) === 1) { + $retryAfterFromDetails = (int) $matches['seconds']; + + if ($retryAfterFromDetails > 0 || $retryAfterFromMessage === null) { + return $retryAfterFromDetails; + } + } + + return $retryAfterFromMessage; + } + + /** + * @param array $responseData + */ + private function buildResetsAtFromResponse(array $responseData): ?Carbon + { + $retryAfter = $this->extractRetryAfterSeconds($responseData); + + return $retryAfter === null ? null : Carbon::now()->addSeconds($retryAfter); + } + + private function extractRetryAfterFromMessage(mixed $message): ?int + { + if (is_string($message) && preg_match('/Please retry in (?[0-9.]+)(?ms|s)\./', $message, $matches) === 1) { + $delay = (float) $matches['delay']; + + return $matches['unit'] === 'ms' + ? (int) ceil($delay / 1000) + : (int) ceil($delay); + } + + return null; + } + + private function toNullableInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + if (! is_string($value) || preg_match('/^\d+$/', $value) !== 1) { + return null; + } + + return (int) $value; + } +} diff --git a/src/Providers/Gemini/Gemini.php b/src/Providers/Gemini/Gemini.php index f8e845aaa..8f044a42c 100644 --- a/src/Providers/Gemini/Gemini.php +++ b/src/Providers/Gemini/Gemini.php @@ -18,6 +18,7 @@ use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Images\Request as ImagesRequest; use Prism\Prism\Images\Response as ImagesResponse; +use Prism\Prism\Providers\Gemini\Concerns\ProcessRateLimits; use Prism\Prism\Providers\Gemini\Handlers\Audio; use Prism\Prism\Providers\Gemini\Handlers\Cache; use Prism\Prism\Providers\Gemini\Handlers\Embeddings; @@ -36,6 +37,7 @@ class Gemini extends Provider { use InitializesClient; + use ProcessRateLimits; public function __construct( #[\SensitiveParameter] public readonly string $apiKey, @@ -110,8 +112,13 @@ public function stream(TextRequest $request): Generator public function handleRequestException(string $model, RequestException $e): never { + $responseData = $e->response->json() ?? []; + match ($e->response->getStatusCode()) { - 429 => throw PrismRateLimitedException::make([]), + 429 => throw PrismRateLimitedException::make( + rateLimits: $this->processRateLimits($responseData), + retryAfter: $this->extractRetryAfterSeconds($responseData) + ), 503 => throw PrismProviderOverloadedException::make(class_basename($this)), default => $this->handleResponseErrors($e), }; diff --git a/tests/Providers/Gemini/ExceptionHandlingTest.php b/tests/Providers/Gemini/ExceptionHandlingTest.php index 4805aa461..eec31bd78 100644 --- a/tests/Providers/Gemini/ExceptionHandlingTest.php +++ b/tests/Providers/Gemini/ExceptionHandlingTest.php @@ -38,6 +38,45 @@ function createGeminiMockResponse(int $statusCode, array $json = []): Response ->toThrow(PrismRateLimitedException::class); }); +it('extracts retry_after and rate limit details from quota violations', function (): void { + $mockResponse = createGeminiMockResponse(429, [ + 'error' => [ + 'code' => 429, + 'message' => 'You exceeded your current quota. Please retry in 35.458759309s.', + 'status' => 'RESOURCE_EXHAUSTED', + 'details' => [ + [ + '@type' => 'type.googleapis.com/google.rpc.QuotaFailure', + 'violations' => [ + [ + 'quotaMetric' => 'generativelanguage.googleapis.com/generate_content_paid_tier_input_token_count', + 'quotaId' => 'GenerateContentPaidTierInputTokensPerModelPerMinute', + 'quotaValue' => '16000', + ], + ], + ], + [ + '@type' => 'type.googleapis.com/google.rpc.RetryInfo', + 'retryDelay' => '35s', + ], + ], + ], + ]); + $exception = new RequestException($mockResponse); + + try { + $this->provider->handleRequestException('gemini-pro', $exception); + $this->fail('Expected PrismRateLimitedException to be thrown.'); + } catch (PrismRateLimitedException $e) { + expect($e->retryAfter)->toBe(35) + ->and($e->rateLimits)->toHaveCount(1) + ->and($e->rateLimits[0]->name)->toBe('GenerateContentPaidTierInputTokensPerModelPerMinute') + ->and($e->rateLimits[0]->limit)->toBe(16000) + ->and($e->rateLimits[0]->remaining)->toBeNull() + ->and($e->rateLimits[0]->resetsAt)->not->toBeNull(); + } +}); + it('handles provider overloaded errors (503)', function (): void { $mockResponse = createGeminiMockResponse(503, []); $exception = new RequestException($mockResponse); From f854714ca9cf6aa1fcd6313d11ad56da122b2bfb Mon Sep 17 00:00:00 2001 From: cutlope <34349994+cutlope@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:47:02 +0500 Subject: [PATCH 2/2] refactor(gemini): rename and replace ProcessRateLimits trait with ProcessesRateLimits - Renamed the `ProcessRateLimits` trait to `ProcessesRateLimits` for consistency.. --- .../Gemini/Concerns/ProcessRateLimits.php | 110 ------------------ .../Gemini/Concerns/ProcessesRateLimits.php | 79 +++++++++++++ src/Providers/Gemini/Gemini.php | 4 +- 3 files changed, 81 insertions(+), 112 deletions(-) delete mode 100644 src/Providers/Gemini/Concerns/ProcessRateLimits.php create mode 100644 src/Providers/Gemini/Concerns/ProcessesRateLimits.php diff --git a/src/Providers/Gemini/Concerns/ProcessRateLimits.php b/src/Providers/Gemini/Concerns/ProcessRateLimits.php deleted file mode 100644 index 352b106bf..000000000 --- a/src/Providers/Gemini/Concerns/ProcessRateLimits.php +++ /dev/null @@ -1,110 +0,0 @@ - $responseData - * @return ProviderRateLimit[] - */ - protected function processRateLimits(array $responseData): array - { - $violations = data_get($responseData, 'error.details', []); - - if (! is_array($violations)) { - return []; - } - - $quotaFailure = collect($violations) - ->first(fn (mixed $detail): bool => data_get($detail, '@type') === 'type.googleapis.com/google.rpc.QuotaFailure'); - - $quotaViolations = data_get($quotaFailure, 'violations', []); - - if (! is_array($quotaViolations)) { - return []; - } - - return collect($quotaViolations) - ->filter(fn (mixed $violation): bool => is_array($violation)) - ->map(fn (array $violation): ProviderRateLimit => new ProviderRateLimit( - name: (string) data_get($violation, 'quotaId', data_get($violation, 'quotaMetric', 'quota')), - limit: $this->toNullableInt(data_get($violation, 'quotaValue')), - remaining: null, - resetsAt: $this->buildResetsAtFromResponse($responseData) - )) - ->all(); - } - - /** - * @param array $responseData - */ - protected function extractRetryAfterSeconds(array $responseData): ?int - { - $details = data_get($responseData, 'error.details', []); - - if (! is_array($details)) { - return null; - } - - $retryInfo = collect($details) - ->first(fn (mixed $detail): bool => data_get($detail, '@type') === 'type.googleapis.com/google.rpc.RetryInfo'); - - $retryDelay = data_get($retryInfo, 'retryDelay'); - - $retryAfterFromMessage = $this->extractRetryAfterFromMessage( - data_get($responseData, 'error.message') - ); - - if (is_string($retryDelay) && preg_match('/^(?\d+)(?:\.\d+)?s$/', $retryDelay, $matches) === 1) { - $retryAfterFromDetails = (int) $matches['seconds']; - - if ($retryAfterFromDetails > 0 || $retryAfterFromMessage === null) { - return $retryAfterFromDetails; - } - } - - return $retryAfterFromMessage; - } - - /** - * @param array $responseData - */ - private function buildResetsAtFromResponse(array $responseData): ?Carbon - { - $retryAfter = $this->extractRetryAfterSeconds($responseData); - - return $retryAfter === null ? null : Carbon::now()->addSeconds($retryAfter); - } - - private function extractRetryAfterFromMessage(mixed $message): ?int - { - if (is_string($message) && preg_match('/Please retry in (?[0-9.]+)(?ms|s)\./', $message, $matches) === 1) { - $delay = (float) $matches['delay']; - - return $matches['unit'] === 'ms' - ? (int) ceil($delay / 1000) - : (int) ceil($delay); - } - - return null; - } - - private function toNullableInt(mixed $value): ?int - { - if (is_int($value)) { - return $value; - } - - if (! is_string($value) || preg_match('/^\d+$/', $value) !== 1) { - return null; - } - - return (int) $value; - } -} diff --git a/src/Providers/Gemini/Concerns/ProcessesRateLimits.php b/src/Providers/Gemini/Concerns/ProcessesRateLimits.php new file mode 100644 index 000000000..0e06269f1 --- /dev/null +++ b/src/Providers/Gemini/Concerns/ProcessesRateLimits.php @@ -0,0 +1,79 @@ + $responseData + * @return ProviderRateLimit[] + */ + protected function processRateLimits(array $responseData): array + { + $quotaViolations = data_get($this->responseDetail($responseData, 'QuotaFailure'), 'violations', []); + + if (! is_array($quotaViolations)) { + return []; + } + + $resetsAt = $this->buildResetsAtFromResponse($responseData); + + return collect($quotaViolations) + ->filter(fn (mixed $violation): bool => is_array($violation)) + ->map(fn (array $violation): ProviderRateLimit => new ProviderRateLimit( + name: (string) data_get($violation, 'quotaId', data_get($violation, 'quotaMetric', 'quota')), + limit: $this->toNullableInt(data_get($violation, 'quotaValue')), + remaining: null, + resetsAt: $resetsAt + )) + ->all(); + } + + /** + * @param array $responseData + */ + protected function extractRetryAfterSeconds(array $responseData): ?int + { + $retryDelay = data_get($this->responseDetail($responseData, 'RetryInfo'), 'retryDelay'); + + if (is_string($retryDelay) && preg_match('/^(?\d+)(?:\.\d+)?s$/', $retryDelay, $matches) === 1) { + return (int) $matches['seconds']; + } + + return null; + } + + /** + * @param array $responseData + */ + private function buildResetsAtFromResponse(array $responseData): ?Carbon + { + $retryAfter = $this->extractRetryAfterSeconds($responseData); + + return $retryAfter === null ? null : Carbon::now()->addSeconds($retryAfter); + } + + private function toNullableInt(mixed $value): ?int + { + return is_int($value) || (is_string($value) && preg_match('/^\d+$/', $value) === 1) + ? (int) $value + : null; + } + + /** + * @param array $responseData + */ + private function responseDetail(array $responseData, string $type): mixed + { + $details = data_get($responseData, 'error.details', []); + + return is_array($details) + ? collect($details)->first(fn (mixed $detail): bool => data_get($detail, '@type') === "type.googleapis.com/google.rpc.$type") + : null; + } +} diff --git a/src/Providers/Gemini/Gemini.php b/src/Providers/Gemini/Gemini.php index 8f044a42c..5673bbcf7 100644 --- a/src/Providers/Gemini/Gemini.php +++ b/src/Providers/Gemini/Gemini.php @@ -18,7 +18,7 @@ use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Images\Request as ImagesRequest; use Prism\Prism\Images\Response as ImagesResponse; -use Prism\Prism\Providers\Gemini\Concerns\ProcessRateLimits; +use Prism\Prism\Providers\Gemini\Concerns\ProcessesRateLimits; use Prism\Prism\Providers\Gemini\Handlers\Audio; use Prism\Prism\Providers\Gemini\Handlers\Cache; use Prism\Prism\Providers\Gemini\Handlers\Embeddings; @@ -37,7 +37,7 @@ class Gemini extends Provider { use InitializesClient; - use ProcessRateLimits; + use ProcessesRateLimits; public function __construct( #[\SensitiveParameter] public readonly string $apiKey,