Настройка обработки ошибок в Background Jobs (retry, dead letter, alerting)
Job упал — что дальше? По умолчанию Laravel просто пометит задачу как failed и забудет. Без retry-логики, без 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 как failed, без использования оставшихся попыток. 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 часа.







