Pekerjaan latar belakang hidup di dunia yang tidak stabil: koneksi SMTP putus sebentar, API pihak ketiga membalas 500, query database kena lock. Menggagalkan pekerjaan hanya karena satu hambatan sesaat akan mengecewakan — padahal sering kali percobaan kedua langsung berhasil. Laravel menjawabnya dengan kebijakan percobaan ulang yang melekat pada job: berapa kali boleh dicoba, berapa lama jeda antar percobaan, sampai kapan percobaan diperbolehkan, dan berapa lama satu eksekusi boleh berjalan. Pada chapter ini kita atur keempat tuas itu di atas job yang benar-benar gagal, lalu menutup dengan pertanyaan kapan retry justru membuat keadaan lebih buruk.

tries: Jumlah Percobaan #

Kita siapkan job impor data yang meniru API flaky — gagal pada dua percobaan pertama, berhasil pada ketiga:

php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use RuntimeException;

class ImportRows implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    /** @var array<int> */
    public array $backoff = [2, 4];

    public function __construct(public int $totalBaris)
    {
        //
    }

    public function handle(): void
    {
        Log::info("ImportRows: percobaan ke-{$this->attempts()} dari {$this->tries}");

        // API eksternal flaky: dua panggilan pertama gagal, ketiga berhasil
        if ($this->attempts() < 3) {
            throw new RuntimeException(
                "API sedang tidak stabil (percobaan {$this->attempts()}/{$this->tries})"
            );
        }
    }
}

Properti $tries = 3 memberi job tiga nyawa, dan method $this->attempts() dari trait Queueable melaporkan percobaan yang sedang berjalan. Setelah dispatch, kita jalankan worker selama delapan detik dan mengamati kronologinya:

bash
php artisan queue:work --sleep=1 --max-time=8

Output yang Dihasilkan:

text
2026-10-13 20:29:48 App\Jobs\ImportRows ............................ RUNNING
2026-10-13 20:29:48 App\Jobs\ImportRows ........................ 4.78ms FAIL
2026-10-13 20:29:50 App\Jobs\ImportRows ............................ RUNNING
2026-10-13 20:29:50 App\Jobs\ImportRows ........................ 1.01ms FAIL
2026-10-13 20:29:54 App\Jobs\ImportRows ............................ RUNNING
2026-10-13 20:29:54 App\Jobs\ImportRows ........................ 0.95ms DONE
2026-10-13 20:29:56 Worker STOPPED Maximum run time exceeded

Job gagal dua kali lalu sukses di percobaan ketiga. Baris log memperkuat jejak attempts():

text
[2026-10-13 20:29:48] local.INFO: ImportRows: percobaan ke-1 dari 3
[2026-10-13 20:29:50] local.INFO: ImportRows: percobaan ke-2 dari 3
[2026-10-13 20:29:54] local.INFO: ImportRows: percobaan ke-3 dari 3

Di balik layar, job yang gagal tidak dihapus: worker menuliskannya kembali ke tabel jobs dengan kolom attempts bertambah dan available_at ditunda sesuai jeda. Bila attempts mencapai $tries namun masih gagal juga, barisnya pindah ke tabel failed_jobs dan menunggu penanganan manual di chapter berikutnya.

backoff: Jeda Antar Percobaan #

Retry tanpa jeda itu kejam bagi sistem yang sedang sesak — percobaan ulang menumpuk tepat saat layanan sedang tumbang. Properti $backoff mengatur jeda dalam detik dan bisa berbentuk array:

php
public array $backoff = [2, 4];

Array dibaca per percobaan: elemen pertama (2 detik) dipakai setelah gagal pertama, elemen kedua (4 detik) setelah gagal kedua, dan seterusnya — sehingga jeda bisa tumbuh eksponensial untuk job yang butuh ruang napas. Bila percobaan melebihi jumlah elemen, elemen terakhir terus dipakai; nilai tunggal seperti public int $backoff = 60; berlaku untuk semua percobaan. Timestamp pada output sebelumnya membuktikan pola [2, 4] bekerja: percobaan kedua datang 2 detik setelah yang pertama (:48 → :50), percobaan ketiga 4 detik setelahnya (:50 → :54).

Kedua properti ini ikut terserialisasi ke payload job — worker membaca kebijakan dari situ, bukan dari class, karena kode class bisa saja berubah sebelum job sempat dikerjakan. Dump baris antrean untuk job ImportRows menunjukkan nilai maxTries: 3 dan backoff: 2,4 tersimpan di dalamnya.

retryUntil: Batas Waktu Percobaan #

Kadang batas yang masuk akal bukan jumlah percobaan, melainkan tenggat waktu. Method retryUntil() mengembalikan batas itu dan mengesampingkan $tries:

php
class RebuildSearchIndex implements ShouldQueue
{
    use Queueable;

    public int $backoff = 2;

    /**
     * Batas waktu job boleh terus dicoba ulang.
     */
    public function retryUntil(): \DateTime
    {
        return now()->addSeconds(5);
    }

    public function __construct(public int $postId)
    {
        //
    }

    public function handle(): void
    {
        // layanan pencarian sedang down
        throw new RuntimeException('Elasticsearch tidak merespons');
    }
}

Hasil retryUntil() dievaluasi sekali saat dispatch dan tersimpan sebagai timestamp di payload — pada pengujian kita, tersisa tepat 5 detik dari waktu dispatch. Job ini selalu gagal, jadi mari kita lihat bagaimana tenggatnya memotong percobaan (php artisan queue:work --sleep=1 --max-time=12):

text
2026-10-13 20:28:52 App\Jobs\RebuildSearchIndex .................... RUNNING
2026-10-13 20:28:52 App\Jobs\RebuildSearchIndex ................ 2.39ms FAIL
2026-10-13 20:28:54 App\Jobs\RebuildSearchIndex .................... RUNNING
2026-10-13 20:28:54 App\Jobs\RebuildSearchIndex ................ 0.98ms FAIL
2026-10-13 20:28:56 App\Jobs\RebuildSearchIndex .................... RUNNING
2026-10-13 20:28:56 App\Jobs\RebuildSearchIndex ................ 1.23ms FAIL
2026-10-13 20:28:58 App\Jobs\RebuildSearchIndex .................... RUNNING
2026-10-13 20:28:58 App\Jobs\RebuildSearchIndex ................ 0.92ms FAIL
2026-10-13 20:29:04 Worker STOPPED Maximum run time exceeded

Dengan backoff 2 detik, job dicoba pada detik ke-0, 2, dan 4. Pada pop berikutnya (detik ke-6, baris RUNNING terakhir) tenggat 5 detik sudah terlampaui — worker menandai job gagal permanen tanpa menjalankan handle() lagi, lalu memindahkannya ke failed_jobs dengan exception MaxAttemptsExceededException. Pola ini cocok untuk data yang basi setelah beberapa menit: laporan analitik yang ditunggu berjam-jam sudah kehilangan nilainya.

timeout: Membunuh Job yang Menggantung #

Semua kebijakan di atas mengasumsikan job yang gagal dengan melempar exception. Musuh yang berbeda adalah job yang tidak pernah selesai — query menggantung, koneksi tanpa timeout — dan properti $timeout memberi worker sekung untuk membunuhnya:

php
class GenerateSlowReport implements ShouldQueue
{
    use Queueable;

    public int $tries = 1;

    public int $timeout = 3;

    public function __construct(public int $bulan)
    {
        //
    }

    public function handle(): void
    {
        // simulasi query agregat berat yang menggantung
        sleep(10);
    }
}

Kita dispatch job itu dan menjalankan php artisan queue:work --stop-when-empty:

text
jobs_pending: 1
waktu_mulai: 20:27:43
2026-10-13 20:27:43 App\Jobs\GenerateSlowReport .................... RUNNING
2026-10-13 20:27:46 App\Jobs\GenerateSlowReport ................ 3s FAIL
2026-10-13 20:27:46 Worker STOPPED Job timed out

Tepat setelah 3 detik — nilai $timeout — job ditandai 3s FAIL dan berpindah ke failed_jobs membawa exception TimeoutExceededException: App\Jobs\GenerateSlowReport has timed out. Proses worker sendiri keluar dengan exit code 137: karena kode job masih terjebak di dalam sleep(10), satu-satunya jalan adalah menghentikan paksa proses itu (SIGKILL) setelah mencatat kegagalannya. Inilah alasan mengapa worker produksi selalu diawasi Supervisor yang menghidupkannya kembali, seperti kita pasang di chapter Queue Workers .

Idempotency: Saat Retry Justru Merusak #

Percobaan ulang aman hanya bila job boleh dijalankan berkali-kali dengan hasil akhir yang sama — sifat yang disebut idempotent. SendWelcomeEmail kita aman: email terkirim dua kali pun tidak merusak data. Job berikut ini tidak:

php
class PotongSaldo implements ShouldQueue
{
    public int $tries = 3;

    public function __construct(public int $rekeningId, public int $nominal) {}

    public function handle(): void
    {
        // BAHAYA: bila exception terjadi SETELAH potong berhasil,
        // saldo terpotong dua kali pada percobaan ulang
        DB::table('rekeningen')->where('id', $this->rekeningId)
            ->decrement('saldo', $this->nominal);
    }
}

Bayangkan decrement sukses tetapi langkah sesudahnya melempar exception: percobaan ulang memotong saldo sekali lagi — user ditagih dua kali. Untuk job non-idempotent, pilihannya: tandai proses dengan id unik dan cek status sebelum mengeksekusi, bungkus langkah dalam satu transaksi database, atau tangani kegagalan eksplisit dengan $this->fail($e) sehingga job tidak dicoba ulang sama sekali. Properti $failOnTimeout = true juga tersedia bila job yang menggantung tidak layak dicoba lagi.

Kebijakan lengkap tries, backoff, retryUntil, dan timeout bisa dirujuk di dokumentasi Queues . Dengan keempat tuas ini kita bisa menyetel job agar tangguh terhadap gangguan sesaat namun tahu kapan harus menyerah — dan setelah menyerah, pekerjaannya berpindah ke wilayah chapter Failed Jobs .