feat(ai): retry pending transactions after a transient provider failure

When a chunk is dropped to a FailoverableException (overload / rate limit),
the affected transactions stay uncategorized with nothing to re-trigger
them: the backfill jobs are one-shot, there is no scheduled backfill, and
the real-time listener only handles new transactions.

Schedule a deferred, per-user RetryTransientAiCategorizationJob that re-reads
the user's still-pending transactions once the provider has had time to
recover. ShouldBeUnique collapses a surge of dropped chunks into a single
retry, and holding the lock through processing bounds it to one attempt per
failure (no chaining). Model cost is negligible, so the retry simply re-runs
the existing backfill rather than tracking which chunks failed.
This commit is contained in:
Víctor Falcón 2026-06-26 19:55:15 +02:00
parent 4f87218c26
commit df67236b85
5 changed files with 166 additions and 5 deletions

View File

@ -0,0 +1,58 @@
<?php
namespace App\Jobs;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use App\Services\Ai\AiCategorizer;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Re-run categorization for a user after a transient AI provider failure
* (overload / rate limit) dropped one or more chunks. De-duplicated per user so
* a surge that drops many chunks queues a single retry, and dispatched with a
* delay so the provider can recover first. It re-reads the user's still-pending
* transactions, so it is a no-op once everything is categorized.
*
* ponytail: the unique lock is held until this job finishes, so a retry that
* overloads again cannot chain a further one exactly one deferred attempt per
* failure. Failed 503s are not billed; lift the lock to ShouldBeUniqueUntilProcessing
* with an attempt cap if a single retry proves too few for long outages.
*/
class RetryTransientAiCategorizationJob implements ShouldBeUnique, ShouldQueue
{
use Queueable;
public int $timeout = 300;
public int $tries = 1;
/**
* Safety TTL for the unique lock in case a worker dies mid-run; comfortably
* longer than the retry delay plus a run.
*/
public int $uniqueFor = 1800;
public function __construct(public User $user) {}
public function viaQueue(): string
{
return (string) config('ai_categorization.queue');
}
public function uniqueId(): string
{
return $this->user->id;
}
public function handle(AiCategorizationGate $gate, AiCategorizer $categorizer): void
{
if (! $gate->allows($this->user)) {
return;
}
$categorizer->backfill($this->user);
}
}

View File

@ -4,6 +4,7 @@ namespace App\Services\Ai;
use App\Ai\Agents\TransactionCategorizationAgent;
use App\Enums\CategorySource;
use App\Jobs\RetryTransientAiCategorizationJob;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Collection;
@ -41,7 +42,7 @@ class CategorizeTransactions
}
$byRef = $transactions->keyBy(fn (Transaction $transaction): string => $transaction->id);
$results = $this->resolve($transactions, $catalog);
$results = $this->resolve($user, $transactions, $catalog);
$labelBar = (float) config('ai_categorization.label_confidence');
$model = (string) config('ai_categorization.model');
@ -103,12 +104,13 @@ class CategorizeTransactions
/**
* Send the transactions to the model in bounded chunks and merge the
* results. A chunk that fails after a retry is dropped without discarding
* the chunks that succeeded.
* the chunks that succeeded; a transient provider failure additionally
* schedules a deferred retry of the user's still-pending transactions.
*
* @param Collection<int, Transaction> $transactions
* @return list<array<string, mixed>>
*/
private function resolve(Collection $transactions, CategoryCatalog $catalog): array
private function resolve(User $user, Collection $transactions, CategoryCatalog $catalog): array
{
$batchSize = max(1, (int) config('ai_categorization.group_batch_size'));
$results = [];
@ -122,6 +124,9 @@ class CategorizeTransactions
Log::warning('AI categorization chunk dropped: provider transient failure.', [
'exception' => $exception->getMessage(),
]);
RetryTransientAiCategorizationJob::dispatch($user)
->delay(now()->addMinutes((int) config('ai_categorization.retry_delay')));
} catch (Throwable $exception) {
report($exception);
}

View File

@ -69,6 +69,19 @@ return [
'queue' => env('AI_CATEGORIZATION_QUEUE', 'ai'),
/*
|--------------------------------------------------------------------------
| Transient-failure retry delay
|--------------------------------------------------------------------------
|
| Minutes to wait before retrying a user's still-pending transactions after
| the AI provider dropped a chunk with a transient failure (overload / rate
| limit). The delay lets the provider recover before we try again.
|
*/
'retry_delay' => (int) env('AI_CATEGORIZATION_RETRY_DELAY', 10),
/*
|--------------------------------------------------------------------------
| Free-plan upsell nudge

View File

@ -4,12 +4,14 @@ use App\Ai\Agents\TransactionCategorizationAgent;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategorySource;
use App\Enums\CategoryType;
use App\Jobs\RetryTransientAiCategorizationJob;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\CategorizeTransactions;
use App\Services\Ai\CategoryCatalog;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Queue;
use Laravel\Ai\Exceptions\ProviderOverloadedException;
function leafIndex(CategoryCatalog $catalog, string $categoryId): int
@ -134,12 +136,13 @@ it('never sends client-side encrypted transactions to the model', function () {
->and($encrypted->category_id)->toBeNull();
});
it('drops the chunk without reporting when the provider is transiently overloaded', function () {
it('drops the chunk, skips reporting and schedules a retry when the provider is transiently overloaded', function () {
$user = User::factory()->create();
groceries($user);
$transaction = uncategorized($user);
Exceptions::fake();
Queue::fake();
TransactionCategorizationAgent::fake(fn () => throw ProviderOverloadedException::forProvider('gemini'));
@ -151,14 +154,19 @@ it('drops the chunk without reporting when the provider is transiently overloade
->and($transaction->category_id)->toBeNull();
Exceptions::assertNothingReported();
Queue::assertPushed(
RetryTransientAiCategorizationJob::class,
fn (RetryTransientAiCategorizationJob $job): bool => $job->user->is($user),
);
});
it('reports unexpected failures so real bugs are not swallowed', function () {
it('reports unexpected failures and does not schedule a retry so real bugs are not swallowed', function () {
$user = User::factory()->create();
groceries($user);
$transaction = uncategorized($user);
Exceptions::fake();
Queue::fake();
TransactionCategorizationAgent::fake(fn () => throw new RuntimeException('malformed response'));
@ -167,6 +175,7 @@ it('reports unexpected failures so real bugs are not swallowed', function () {
expect($outcomes)->toBe([]);
Exceptions::assertReported(fn (RuntimeException $e): bool => $e->getMessage() === 'malformed response');
Queue::assertNotPushed(RetryTransientAiCategorizationJob::class);
});
it('skips results whose category index does not resolve', function () {

View File

@ -0,0 +1,76 @@
<?php
use App\Ai\Agents\TransactionCategorizationAgent;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategorySource;
use App\Enums\CategoryType;
use App\Jobs\RetryTransientAiCategorizationJob;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\CategoryCatalog;
it('categorizes the still-pending transactions when run for a consenting user', function () {
$user = User::factory()->create();
$user->recordAiConsent();
$category = Category::factory()->for($user)->create([
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
$catalog = CategoryCatalog::forUser($user);
$index = 0;
while (($id = $catalog->categoryIdForIndex($index)) !== null && $id !== $category->id) {
$index++;
}
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'category_source' => null,
'amount' => -4300,
'creditor_name' => 'mercadona',
'description' => 'mercadona compra',
]);
TransactionCategorizationAgent::fake([
['results' => [[
'ref' => $transaction->id,
'category_index' => $index,
'confidence' => 0.95,
'merchant_unambiguous' => true,
]]],
]);
app()->call([new RetryTransientAiCategorizationJob($user), 'handle']);
$transaction->refresh();
expect($transaction->category_id)->toBe($category->id)
->and($transaction->category_source)->toBe(CategorySource::Ai);
});
it('does nothing when the user has not consented to AI', function () {
$user = User::factory()->create();
Category::factory()->for($user)->create([
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'creditor_name' => 'mercadona',
]);
TransactionCategorizationAgent::fake([])->preventStrayPrompts();
app()->call([new RetryTransientAiCategorizationJob($user), 'handle']);
$transaction->refresh();
expect($transaction->category_id)->toBeNull();
TransactionCategorizationAgent::assertNeverPrompted();
});