diff --git a/app/Services/Ai/LaravelAiRuleSuggestionGenerator.php b/app/Services/Ai/LaravelAiRuleSuggestionGenerator.php index 109d9c4a..fce2369d 100644 --- a/app/Services/Ai/LaravelAiRuleSuggestionGenerator.php +++ b/app/Services/Ai/LaravelAiRuleSuggestionGenerator.php @@ -4,7 +4,9 @@ namespace App\Services\Ai; use App\Ai\Agents\RuleSuggestionAgent; use App\Services\Ai\Contracts\RuleSuggestionGenerator; +use Illuminate\Support\Facades\Log; use Laravel\Ai\Enums\Lab; +use Laravel\Ai\Exceptions\FailoverableException; use Throwable; class LaravelAiRuleSuggestionGenerator implements RuleSuggestionGenerator @@ -27,6 +29,17 @@ class LaravelAiRuleSuggestionGenerator implements RuleSuggestionGenerator foreach ($this->generateBatchWithRetry($batch, $categoryOptions) as $suggestion) { $suggestions[] = $suggestion; } + } catch (FailoverableException $exception) { + // An overloaded or rate-limited provider is an expected transient + // condition, not a bug (PHP-LARAVEL-44). Count it as a failure so + // an all-transient run still surfaces, but don't report the + // per-batch noise — a genuine total failure is still reported once + // by the run-level handler. Mirrors CategorizeTransactions. + $failures++; + $lastError = $exception; + Log::warning('AI rule-suggestion batch dropped: provider transient failure.', [ + 'exception' => $exception->getMessage(), + ]); } catch (Throwable $exception) { // A single batch failing must not discard the suggestions from // the batches that did succeed (a run can span many batches). diff --git a/tests/Feature/Ai/RuleSuggestionGeneratorTest.php b/tests/Feature/Ai/RuleSuggestionGeneratorTest.php index aa471717..13b60e8a 100644 --- a/tests/Feature/Ai/RuleSuggestionGeneratorTest.php +++ b/tests/Feature/Ai/RuleSuggestionGeneratorTest.php @@ -2,6 +2,8 @@ use App\Ai\Agents\RuleSuggestionAgent; use App\Services\Ai\LaravelAiRuleSuggestionGenerator; +use Illuminate\Support\Facades\Exceptions; +use Laravel\Ai\Exceptions\ProviderOverloadedException; it('returns the structured suggestions produced by the model', function () { RuleSuggestionAgent::fake([ @@ -95,6 +97,55 @@ it('keeps successful batches when one batch fails after retry', function () { ->and($suggestions[0]['match_token'])->toBe('okkey'); }); +it('does not report an expected transient provider overload', function () { + config()->set('ai_suggestions.group_batch_size', 1); + + Exceptions::fake(); + + RuleSuggestionAgent::fake(function (string $prompt) { + if (str_contains($prompt, 'boomtoken')) { + throw ProviderOverloadedException::forProvider('gemini'); + } + + return ['suggestions' => [genSuggestion('okkey')]]; + }); + + $generator = new LaravelAiRuleSuggestionGenerator; + + $suggestions = $generator->generate( + groups: [benchGroup('boomtoken'), benchGroup('goodkey')], + categoryOptions: [['id' => 'cat-1', 'name' => 'X', 'path' => 'X', 'type' => 'expense', 'direction' => 'outflow', 'is_leaf' => true]], + ); + + expect($suggestions)->toHaveCount(1) + ->and($suggestions[0]['match_token'])->toBe('okkey'); + + Exceptions::assertNothingReported(); +}); + +it('reports an unexpected batch failure', function () { + config()->set('ai_suggestions.group_batch_size', 1); + + Exceptions::fake(); + + RuleSuggestionAgent::fake(function (string $prompt) { + if (str_contains($prompt, 'boomtoken')) { + throw new RuntimeException('batch failed'); + } + + return ['suggestions' => [genSuggestion('okkey')]]; + }); + + $generator = new LaravelAiRuleSuggestionGenerator; + + $generator->generate( + groups: [benchGroup('boomtoken'), benchGroup('goodkey')], + categoryOptions: [['id' => 'cat-1', 'name' => 'X', 'path' => 'X', 'type' => 'expense', 'direction' => 'outflow', 'is_leaf' => true]], + ); + + Exceptions::assertReported(RuntimeException::class); +}); + it('rethrows when every batch fails', function () { config()->set('ai_suggestions.group_batch_size', 1);