fix(banking): record the job deaths that never reach handle()

Every `logSyncAttempt` call lived inside `handle()`, which is exactly what an
out-of-band death skips — so a connection's own history showed no trace of the
most common way this job dies. One prod connection has 66 job failures and 3
sync-log rows. That gap is why the first pass of this branch mis-attributed the
stranding to timeouts: the evidence was only in `failed_jobs`.

`duration_ms` becomes nullable at the call site rather than being faked to 0,
because nobody timed the attempt.

The copy changes too. `failed()` wrote "An unexpected error occurred during sync.
Please try again later." — handing our own infrastructure to the user as an
action — while the classified-transient path already promised "we will try
syncing again later" for the same situation. Every message this method writes
describes an out-of-band death, so it now says so and the next scheduled cycle
picks the connection back up on its own.
This commit is contained in:
Víctor Falcón 2026-08-12 09:43:03 +02:00
parent e60fdf4faa
commit e0481df497
4 changed files with 43 additions and 6 deletions

View File

@ -188,8 +188,23 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
$connection->update([
'status' => BankingConnectionStatus::Error,
'error_message' => $e ? $this->friendlyErrorMessage($e) : __('An unexpected error occurred during sync. Please try again later.'),
// Every message written here describes an out-of-band death, so the
// generic "an unexpected error occurred, please try again" was pushing
// our own infrastructure onto the user. The next scheduled cycle picks
// the connection back up on its own.
'error_message' => __('The sync did not finish. We will try again later.'),
]);
// handle() owns every other logSyncAttempt call, and it is precisely what
// an out-of-band death skips - leaving the connection's own history with no
// trace of the most common way this job dies.
$this->logSyncAttempt(
$connection,
BankingSyncLogStatus::Failed,
startTime: null,
error: $e,
metadata: ['reason' => 'job_died_outside_handle'],
);
}
/**
@ -302,22 +317,27 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
});
}
/**
* @param float|null $startTime Null when the caller never got to start a
* timer, so the duration is genuinely unknown
* rather than zero.
*/
private function logSyncAttempt(
BankingConnection $connection,
BankingSyncLogStatus $status,
float $startTime,
?float $startTime,
?\Throwable $error = null,
?array $metadata = null,
): void {
$durationMs = (int) round((microtime(true) - $startTime) * 1000);
BankingSyncLog::create([
'banking_connection_id' => $connection->id,
'status' => $status,
'attempt' => $this->attempts(),
'error_message' => $error?->getMessage(),
'error_class' => $error ? get_class($error) : null,
'duration_ms' => $durationMs,
'duration_ms' => $startTime === null
? null
: (int) round((microtime(true) - $startTime) * 1000),
'metadata' => $metadata,
'created_at' => now(),
]);

View File

@ -332,6 +332,7 @@
"Amount is required": "Se requiere un valor",
"An unexpected error occurred during sync.": "Se produjo un error inesperado durante la sincronización.",
"An unexpected error occurred during sync. Please try again later.": "Ocurrió un error inesperado durante la sincronización. Inténtalo de nuevo más tarde.",
"The sync did not finish. We will try again later.": "La sincronización no se completó. Volveremos a intentarlo más tarde.",
"Annual": "Anual",
"Annual Interest Rate": "Tasa de Interés Anual",
"Annual Interest Rate (%)": "Tasa de Interés Anual (%)",

View File

@ -1642,7 +1642,7 @@ test('failed sync job marks active connection as error so onboarding can continu
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Error);
expect($connection->last_synced_at)->toBeNull();
expect($connection->error_message)->toBe('An unexpected error occurred during sync. Please try again later.');
expect($connection->error_message)->toBe('The sync did not finish. We will try again later.');
// A job killed from the outside says nothing about the connection, so it no
// longer spends a scheduled retry.
expect($connection->consecutive_sync_failures)->toBe(0);

View File

@ -945,3 +945,19 @@ test('a second out-of-band death on an already errored connection changes nothin
expect($connection->consecutive_sync_failures)->toBe(2);
expect($connection->error_message)->toBe('Earlier failure kept.');
});
test('an out-of-band job death is recorded in the connection history', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->create(['user_id' => $user->id]);
(new SyncBankingConnectionJob($connection))->failed(
new TimeoutExceededException('App\Jobs\SyncBankingConnectionJob has timed out.')
);
$log = BankingSyncLog::where('banking_connection_id', $connection->id)->sole();
expect($log->status)->toBe(BankingSyncLogStatus::Failed);
expect($log->error_class)->toBe(TimeoutExceededException::class);
expect($log->metadata['reason'])->toBe('job_died_outside_handle');
// Unknown rather than zero: nobody timed this attempt.
expect($log->duration_ms)->toBeNull();
});