Налаштування обробки помилок у Background Jobs (retry, dead letter, alerting)
Job впав — що далі? За замовчуванням Laravel просто позначає завдання як помилкове і забуває про нього. Без логіки повторів, без dead letter queue, без сповіщень вся обробка помилок відбувається випадково. Правильна архітектура визначає: скільки разів повторити, з яким інтервалом, що робити з остаточно упалими завданнями, кого сигналізувати.
Параметри повторних спроб
У класі Job:
class SendEmailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5; // максимум спроб
public int $backoff = 60; // фіксована пауза між спробами (секунди)
public int $timeout = 30; // таймаут однієї спроби
// Експоненціальний backoff замість фіксованого
public function backoff(): array
{
return [10, 30, 60, 120, 300]; // спроба 1→10с, 2→30с, 3→60с, 4→120с, 5→300с
}
}
backoff() як метод перекриває властивість $backoff. Масив дозволяє задати різні інтервали для кожної спроби — це експоненціальний backoff. Особливо важливо для зовнішніх API: якщо сервіс тимчасово недоступний, не варто чимати його кожні 10 секунд.
Розрізнення помилок: повторювані vs фатальні
Не всі помилки варто повторювати. Невалідний формат даних на другий раз не виправиться — це фатальна помилка. Недоступний API через хвилину може відповісти — це тимчасова:
public function handle(): void
{
try {
$this->processData();
} catch (ValidationException $e) {
// Дані невалідні — повтор беззмістовний
$this->fail($e);
return;
} catch (ModelNotFoundException $e) {
// Запис видалений — повтор не допоможе
$this->fail($e);
return;
} catch (ConnectionException | TimeoutException $e) {
// Тимчасова помилка мережі — повторюємо
throw $e; // дозвольте Queue обробити retry
} catch (\Throwable $e) {
// Невідома помилка — теж повторюємо, але логуємо
Log::warning("Unexpected error in SendEmailJob, attempt {$this->attempts()}: {$e->getMessage()}");
throw $e;
}
}
$this->fail($e) — негайно позначає Job як помилковий, без використання залишених спроб. throw $e — збільшує лічильник спроб і планує повтор.
Метод failed()
Викликається після вичерпання всіх спроб:
public function failed(\Throwable $e): void
{
// Сповістити користувача
if ($this->userId) {
$user = User::find($this->userId);
$user?->notify(new JobFailedNotification($this->jobType, $e->getMessage()));
}
// Логувати з контекстом
Log::error('Job permanently failed', [
'job' => static::class,
'payload' => $this->getPayloadForLog(),
'attempts' => $this->attempts(),
'exception' => [
'class' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile() . ':' . $e->getLine(),
],
]);
// Зберегти у власну таблицю для аудиту
FailedJobAudit::create([
'job_class' => static::class,
'payload' => json_encode($this->getPayloadForLog()),
'error' => $e->getMessage(),
'failed_at' => now(),
]);
// Сповістити DevOps-канал
$this->alertSlack($e);
}
private function getPayloadForLog(): array
{
// Повертаємо лише безпечні дані (без паролів, токенів)
return ['user_id' => $this->userId, 'type' => $this->jobType];
}
Dead Letter Queue
Dead Letter Queue (DLQ) — окрема черга для остаточно упалих завдань. Дозволяє потім проаналізувати та обробити їх вручну або автоматично.
Laravel не реалізує DLQ з коробки, але паттерн несложно побудувати:
// app/Jobs/Middleware/DeadLetterMiddleware.php
class DeadLetterMiddleware
{
public function handle(object $job, callable $next): void
{
try {
$next($job);
} catch (\Throwable $e) {
if ($job->attempts() >= $job->tries) {
// Остання спроба — відправляємо у DLQ
dispatch(new DeadLetterJob(
originalClass: get_class($job),
serializedJob: serialize($job),
errorMessage: $e->getMessage(),
errorTrace: $e->getTraceAsString(),
))->onQueue('dead-letter');
}
throw $e;
}
}
}
Застосуємо middleware до Job'у:
public function middleware(): array
{
return [new DeadLetterMiddleware()];
}
DeadLetterJob — проста обгортка, яка зберігає серіалізоване завдання та дозволяє пізніше його відновити:
class DeadLetterJob implements ShouldQueue
{
public int $tries = 1; // DLQ-завдання не повторюємо
public function __construct(
public string $originalClass,
public string $serializedJob,
public string $errorMessage,
public string $errorTrace,
public \Carbon\Carbon $failedAt = new \Carbon\Carbon(),
) {}
public function handle(): void
{
// Просто зберігаємо для аудиту
DeadLetterRecord::create([
'original_class' => $this->originalClass,
'serialized_job' => $this->serializedJob,
'error_message' => $this->errorMessage,
'failed_at' => $this->failedAt,
]);
}
public function restore(): void
{
$originalJob = unserialize($this->serializedJob);
dispatch($originalJob);
}
}
Команда для повторного запуску завдань з DLQ:
// app/Console/Commands/RetryDeadLetterJobs.php
public function handle(): void
{
DeadLetterRecord::where('failed_at', '>=', now()->subDays(3))
->whereNull('retried_at')
->each(function (DeadLetterRecord $record) {
$job = unserialize($record->serialized_job);
dispatch($job);
$record->update(['retried_at' => now()]);
$this->info("Retried: {$record->original_class} [{$record->id}]");
});
}
Алертинг
Сповіщення в Slack при падінні Job:
private function alertSlack(\Throwable $e): void
{
$env = config('app.env');
$payload = [
'text' => null,
'attachments' => [[
'color' => 'danger',
'title' => "Job Failed [{$env}]",
'fields' => [
['title' => 'Job', 'value' => static::class, 'short' => true],
['title' => 'Error', 'value' => $e->getMessage(), 'short' => false],
['title' => 'Attempts','value' => (string)$this->attempts(), 'short' => true],
['title' => 'Time', 'value' => now()->toDateTimeString(), 'short' => true],
],
'footer' => config('app.url'),
]],
];
rescue(fn() => Http::post(config('services.slack.job_alerts_webhook'), $payload));
}
rescue() обгортає виклик, щоб помилка в алертингу не породжувала рекурсивного падіння.
Моніторинг кількості failed jobs
Періодична перевірка через Artisan-команду в cron:
// app/Console/Commands/CheckFailedJobs.php
public function handle(): void
{
$count = DB::table('failed_jobs')
->where('failed_at', '>=', now()->subHour())
->count();
$threshold = (int) config('queue.failed_jobs_alert_threshold', 10);
if ($count >= $threshold) {
Http::post(config('services.telegram.webhook_url'), [
'chat_id' => config('services.telegram.admin_chat_id'),
'text' => "⚠️ {$count} jobs failed in the last hour",
]);
}
}
// routes/console.php
Schedule::command('queue:check-failed')->everyFiveMinutes();
Сроки
Налаштування retry-стратегії, методу failed(), алертингу — 3–4 години. Реалізація Dead Letter Queue з командою відновлення — ще 4–5 годин. Інтеграція з Horizon та дашбордом моніторингу — 2–3 години.







