Job yang gagal karena gangguan sesaat — API pihak ketiga sedang sibuk, koneksi database terputus sebentar, deadlock yang berlalu sendiri — biasanya akan berhasil jika diberi kesempatan kedua. Namun mencoba ulang tanpa rencana justru berbahaya: retry yang terlalu cepat menghujani sistem yang sedang pulih, retry tanpa batas memenuhi queue dengan pekerjaan mustahil, dan retry tanpa timeout menggantung worker berjam-jam pada satu job. Chapter ini menyusun kebijakan retry yang sehat dari empat komponen: jeda eksponensial (exponential backoff), pengacakan jeda (jitter), klasifikasi kegagalan transien dengan batas percobaan, dan anggaran waktu — semuanya ditulis sebagai kode murni yang bisa diuji.

Backoff Eksponensial: Jeda yang Berlipat Ganda #

Ide dasarnya sederhana: setiap kegagalan berikutnya menunggu lebih lama sebelum mencoba lagi, karena gangguan yang bertahan lama butuh waktu pulih yang lebih panjang. Rumus yang paling umum dipakai adalah jeda = basis × 2^(percobaan − 1) dengan basis jeda awal (misalnya 2 detik) dan sebuah batas atas agar jeda tidak membesar tanpa kendali. Perhatikan wujudnya dalam kode:

php
<?php

function hitungBackoffDetik(int $percobaanKe, int $basisDetik, int $batasDetik): int
{
    $jeda = $basisDetik * (2 ** ($percobaanKe - 1));

    return min($jeda, $batasDetik);
}

for ($percobaan = 1; $percobaan <= 6; $percobaan++) {
    $jeda = hitungBackoffDetik($percobaan, 2, 60);
    echo "Gagal di percobaan ke-{$percobaan}: tunggu {$jeda} detik sebelum mencoba lagi\n";
}

Output yang Dihasilkan:

text
Gagal di percobaan ke-1: tunggu 2 detik sebelum mencoba lagi
Gagal di percobaan ke-2: tunggu 4 detik sebelum mencoba lagi
Gagal di percobaan ke-3: tunggu 8 detik sebelum mencoba lagi
Gagal di percobaan ke-4: tunggu 16 detik sebelum mencoba lagi
Gagal di percobaan ke-5: tunggu 32 detik sebelum mencoba lagi
Gagal di percobaan ke-6: tunggu 60 detik sebelum mencoba lagi

Mari kita bedah komponen-komponennya:

  1. hitungBackoffDetik() adalah fungsi murni: keluarannya hanya ditentukan oleh tiga argumennya, tanpa menyentuh jam, database, atau keadaan luar. Sifat inilah yang membuatnya mudah diuji nanti.
  2. 2 ** ($percobaanKe - 1) adalah operator pangkat yang menghasilkan penggandaan: percobaan pertama menunggu basis, percobaan kedua basis × 2, percobaan ketiga basis × 4, dan seterusnya.
  3. min($jeda, $batasDetik) memotong jeda pada batas atas — terlihat jelas di percobaan ke-6 yang seharusnya 64 detik tetapi terpotong menjadi 60.
  4. Loop for hanya mencetak simulasi jadwal; worker sesungguhnya akan memanggil sleep($jeda) atau, lebih baik lagi, menjadwalkan ulang job ke queue dengan available_at di masa depan.

Agregat jadwalnya membentuk gambaran berikut untuk basis 2 detik dan batas 60 detik:

Percobaan gagal ke-Jeda tungguTotal menunggu sejak kegagalan pertama
12 detik2 detik
24 detik6 detik
38 detik14 detik
416 detik30 detik
532 detik62 detik
660 detik (terpotong batas)122 detik

Angka basis sebaiknya dipilih dari latensi normal dependensi Anda: jika API biasanya menjawab dalam 200 milidetik, basis 1–2 detik sudah memberi ruang pulih yang cukup. Batas atas 60 detik menjaga job tetap “hidup” di mata pengguna untuk interaksi cepat; untuk proses latar belakang yang tidak diawasi manusia, batas beberapa menit juga lazim.

Jitter: Pengacakan Jeda agar Worker Tidak Berbaris #

Backoff murni punya satu kelemahan yang tak terlihat: semua worker yang gagal pada saat yang sama akan mencoba ulang pada saat yang sama pula. Saat satu instance database restart, 500 job gagal serentak, dan 500 job tersebut akan menyerang database lagi di detik yang persis sama — pukulan kedua yang bisa menjatuhkannya lagi. Fenomena ini dikenal sebagai thundering herd. Penawarnya adalah jitter: acak jeda setiap job dalam rentang tertentu sehingga beban retry menyebar.

Bentuk yang paling sederhana dan efektif disebut full jitter — pilih angka acak antara 0 dan jeda backoff:

php
<?php

function hitungBackoffDetik(int $percobaanKe, int $basisDetik, int $batasDetik): int
{
    $jeda = $basisDetik * (2 ** ($percobaanKe - 1));

    return min($jeda, $batasDetik);
}

function jitterPenuh(int $jedaMaksDetik, Closure $sumberAcak): int
{
    return $sumberAcak(0, $jedaMaksDetik);
}

mt_srand(42);
$sumberAcak = mt_rand(...);

for ($percobaan = 1; $percobaan <= 4; $percobaan++) {
    $jedaDasar = hitungBackoffDetik($percobaan, 2, 60);
    $jedaAkhir = jitterPenuh($jedaDasar, $sumberAcak);
    echo "percobaan ke-{$percobaan}: dasar {$jedaDasar} detik, setelah jitter {$jedaAkhir} detik\n";
}

Output yang Dihasilkan:

text
percobaan ke-1: dasar 2 detik, setelah jitter 0 detik
percobaan ke-2: dasar 4 detik, setelah jitter 2 detik
percobaan ke-3: dasar 8 detik, setelah jitter 7 detik
percobaan ke-4: dasar 16 detik, setelah jitter 12 detik

Tiga keputusan desain ada di contoh tersebut:

  1. jitterPenuh() tidak memanggil fungsi acak secara langsung, melainkan menerima Closure $sumberAcak sebagai parameter. Sumber acak menjadi sesuatu yang bisa disuntikkan — di production Anda menyuntik random_int(...), di unit test Anda menyuntik closure yang selalu mengembalikan angka tetap.
  2. mt_srand(42) di awal demo membekukan deret angka mt_rand() sehingga output contoh ini sama setiap kali dijalankan. Ini murni trik presentasi; kode production harus memakai random_int() yang kriptografis aman dan tidak bisa (memang tidak boleh) dibekukan.
  3. Hasil jitter selalu berada di rentang 0 sampai jeda dasar — percobaan pertama boleh langsung terjadi kembali (jeda 0), yang membantu kasus gangguan sesaat nan kilat.

Runner Retry: Klasifikasi, Batas Percobaan, dan Dead-Letter #

Sekarang kita rangkai semua komponen menjadi satu runner yang menjalankan tugas, mengklasifikasikan kegagalan, dan memutuskan nasib job. Dua keputusan dari chapter Job Failure Handling kita pakai ulang di sini: hanya kegagalan transien yang boleh diulang (dideteksi lewat marker interface kosong), dan job yang kehabisan kesempatan dipindahkan ke dead-letter daripada diulang selamanya. Satu tambahan kunci: waktu dibaca lewat JamInterface, bukan date() langsung, sehingga jadwal retry bisa dihitung dan diuji secara deterministik:

php
<?php

interface KegagalanTransien
{
}

final class TimeoutApi extends RuntimeException implements KegagalanTransien
{
}

interface JamInterface
{
    public function sekarang(): DateTimeImmutable;
}

final class JamBeku implements JamInterface
{
    public function __construct(private readonly DateTimeImmutable $waktu)
    {
    }

    public function sekarang(): DateTimeImmutable
    {
        return $this->waktu;
    }
}

function jalankanDenganRetry(callable $tugas, int $maksPercobaan, JamInterface $jam): array
{
    $log = [];

    for ($percobaan = 1; $percobaan <= $maksPercobaan; $percobaan++) {
        try {
            $tugas($percobaan);
            $log[] = "percobaan {$percobaan}: BERHASIL";

            return [true, $log];
        } catch (Throwable $kegagalan) {
            if (!$kegagalan instanceof KegagalanTransien) {
                $log[] = "percobaan {$percobaan}: PERMANEN, langsung ke dead-letter";

                return [false, $log];
            }

            if ($percobaan >= $maksPercobaan) {
                $log[] = "percobaan {$percobaan}: transien tapi batas tercapai, ke dead-letter";

                return [false, $log];
            }

            $jeda = min(2 * (2 ** ($percobaan - 1)), 60);
            $jadwal = $jam->sekarang()->add(new DateInterval("PT{$jeda}S"));
            $log[] = "percobaan {$percobaan}: transien, retry dijadwalkan jam " . $jadwal->format("H:i:s");
        }
    }

    return [false, $log];
}

$jam = new JamBeku(new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta")));

$skenario = [
    "sembuh di percobaan ketiga" => static function (int $percobaanKe): void {
        if ($percobaanKe < 3) {
            throw new TimeoutApi("API tidak merespons");
        }
    },
    "kegagalan permanen" => static function (int $percobaanKe): void {
        throw new InvalidArgumentException("Field orderId wajib berupa integer");
    },
    "transien sampai batas" => static function (int $percobaanKe): void {
        throw new TimeoutApi("API tidak merespons");
    },
];

foreach ($skenario as $nama => $tugas) {
    [$berhasil, $log] = jalankanDenganRetry($tugas, 4, $jam);
    $nasib = $berhasil ? "BERHASIL" : "DEAD-LETTER";
    echo "=== {$nama} => {$nasib} ===\n";
    foreach ($log as $baris) {
        echo "  {$baris}\n";
    }
}

Output yang Dihasilkan:

text
=== sembuh di percobaan ketiga => BERHASIL ===
  percobaan 1: transien, retry dijadwalkan jam 08:00:02
  percobaan 2: transien, retry dijadwalkan jam 08:00:04
  percobaan 3: BERHASIL
=== kegagalan permanen => DEAD-LETTER ===
  percobaan 1: PERMANEN, langsung ke dead-letter
=== transien sampai batas => DEAD-LETTER ===
  percobaan 1: transien, retry dijadwalkan jam 08:00:02
  percobaan 2: transien, retry dijadwalkan jam 08:00:04
  percobaan 3: transien, retry dijadwalkan jam 08:00:08
  percobaan 4: transien tapi batas tercapai, ke dead-letter

Alur runner tersebut berjalan sebagai berikut:

  1. Blok try memanggil tugas dengan nomor percobaan; tugas demo kita berupa closure yang melempar exception sesuai skenario.
  2. instanceof KegagalanTransien adalah gerbang pertama: exception yang tidak menandai dirinya transien (seperti InvalidArgumentException dari payload rusak) langsung dikirim ke dead-letter tanpa satu pun retry.
  3. $percobaan >= $maksPercobaan adalah gerbang kedua: kegagalan transien yang sudah menghabiskan jatah percobaan juga berakhir di dead-letter — di sistem nyata, saat itulah worker menulis ke tabel failed_jobs seperti pada chapter Job Failure Handling .
  4. $jam->sekarang()->add(...) menghitung jadwal retry berikutnya dari jam yang disuntikkan. JamBeku membekukan waktu pada pukul 08:00:00, sehingga jadwal 08:00:02, 08:00:04, dan 08:00:08 muncul konsisten — bukti visual bahwa logika waktu tidak lagi bergantung pada jam dinding.
  5. Nilai balik [bool, array] memisahkan hasil (berhasil atau tidak) dari jejak keputusan, sehingga pemanggil bisa mencatat log atau menegaskan isi jejaknya saat diuji.

Membuktikan Kebijakan Lewat Unit Test #

Desain di atas sengaja menjauhkan sumber ketidakpastian: waktu disuntikkan lewat JamInterface, keacakan disuntikkan lewat Closure, dan keputusan adalah fungsi murni. Berikut berkas test lengkap yang membuktikannya (PHPUnit dibahas dari dasar pada Section Testing dan Quality Assurance — untuk sekarang, bacalah test sebagai daftar periksa yang dieksekusi otomatis):

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

interface KegagalanTransien
{
}

final class TimeoutApi extends RuntimeException implements KegagalanTransien
{
}

interface JamInterface
{
    public function sekarang(): DateTimeImmutable;
}

final class JamBeku implements JamInterface
{
    public function __construct(private readonly DateTimeImmutable $waktu)
    {
    }

    public function sekarang(): DateTimeImmutable
    {
        return $this->waktu;
    }
}

function hitungBackoffDetik(int $percobaanKe, int $basisDetik, int $batasDetik): int
{
    $jeda = $basisDetik * (2 ** ($percobaanKe - 1));

    return min($jeda, $batasDetik);
}

function jitterPenuh(int $jedaMaksDetik, Closure $sumberAcak): int
{
    return $sumberAcak(0, $jedaMaksDetik);
}

function jalankanDenganRetry(callable $tugas, int $maksPercobaan, JamInterface $jam): array
{
    $log = [];

    for ($percobaan = 1; $percobaan <= $maksPercobaan; $percobaan++) {
        try {
            $tugas($percobaan);
            $log[] = "percobaan {$percobaan}: BERHASIL";

            return [true, $log];
        } catch (Throwable $kegagalan) {
            if (!$kegagalan instanceof KegagalanTransien) {
                $log[] = "percobaan {$percobaan}: PERMANEN, langsung ke dead-letter";

                return [false, $log];
            }

            if ($percobaan >= $maksPercobaan) {
                $log[] = "percobaan {$percobaan}: transien tapi batas tercapai, ke dead-letter";

                return [false, $log];
            }

            $jeda = min(2 * (2 ** ($percobaan - 1)), 60);
            $jadwal = $jam->sekarang()->add(new DateInterval("PT{$jeda}S"));
            $log[] = "percobaan {$percobaan}: transien, retry dijadwalkan jam " . $jadwal->format("H:i:s");
        }
    }

    return [false, $log];
}

final class KebijakanRetryTest extends TestCase
{
    public static function penyediaJedaBackoff(): array
    {
        return [
            "percobaan pertama" => [1, 2],
            "percobaan kedua" => [2, 4],
            "percobaan ketiga" => [3, 8],
            "terpotong di batas atas" => [6, 60],
        ];
    }

    #[DataProvider("penyediaJedaBackoff")]
    public function testJedaBackoffEksponensial(int $percobaanKe, int $jedaDiharapkan): void
    {
        $this->assertSame($jedaDiharapkan, hitungBackoffDetik($percobaanKe, 2, 60));
    }

    public function testJitterMengikutiSumberAcakYangDisuntikkan(): void
    {
        $sumberTetap = static fn (int $min, int $maks): int => 7;

        $this->assertSame(7, jitterPenuh(10, $sumberTetap));
    }

    public function testTransienYangTakPernahSembuhBerakhirDiDeadLetter(): void
    {
        $tugas = static function (int $percobaanKe): void {
            throw new TimeoutApi("API tidak merespons");
        };
        $jam = new JamBeku(new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta")));

        [$berhasil, $log] = jalankanDenganRetry($tugas, 3, $jam);

        $this->assertFalse($berhasil);
        $this->assertCount(3, $log);
        $this->assertSame(
            "percobaan 3: transien tapi batas tercapai, ke dead-letter",
            $log[2],
        );
    }
}

Untuk keperluan demo, seluruh definisi dari blok-blok sebelumnya disertakan ulang di bagian atas berkas test sehingga berkas berdiri sendiri. Suite dijalankan dengan satu perintah:

bash
php /home/z/my-project/bin/phpunit.phar tests

Berikut output nyata dari perintah tersebut:

text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

......                                                              6 / 6 (100%)

Time: 00:00.002, Memory: 28.05 MB

OK (6 tests, 8 assertions)

Enam test tersebut membungkus tiga klaim kebijakan: jadwal backoff tumbuh eksponensial dan terpotong di batas atas (empat kasus lewat data provider), jitter tunduk pada sumber acak yang disuntikkan, dan kegagalan transien yang tak pernah sembuh berakhir di dead-letter setelah tepat tiga percobaan. Tidak ada satu pun test yang menunggu timer sungguhan atau bergantung pada jam dinding — itulah hadiah dari desain yang menyuntikkan waktu dan keacakan dari luar.

Anggaran Waktu: Timeout per Percobaan dan Total #

Komponen terakhir adalah batas waktu. Ada dua lapis yang saling melengkapi: per-try timeout membatasi berapa lama satu percobaan boleh berjalan (mencegah worker menggantung pada satu job), dan total budget membatasi akumulasi waktu seluruh percobaan untuk satu job (mencegah job menghuni queue terlalu lama). Untuk per-try timeout pada panggilan HTTP, konfigurasinya hidup di opsi klien:

php
<?php

declare(strict_types=1);

$ch = curl_init("https://api.example.com/v1/pembayaran");
curl_setopt($ch, CURLOPT_CONNECTTIMETIMEOUT, 2);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$isi = curl_exec($ch);
$kodeHttp = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

Contoh di atas tidak dieksekusi otomatis karena membutuhkan koneksi ke server luar; CURLOPT_CONNECTTIMEOUT membatasi 2 detik untuk membangun koneksi, sedangkan CURLOPT_TIMEOUT membatasi total 5 detik untuk seluruh permintaan. Kegagalan karena batas ini muncul sebagai timeout yang — jika exception Anda menandainya transien — akan dijemput oleh retry runner. Lapis kedua, anggaran total, adalah keputusan kecil yang bisa diuji:

php
<?php

final class AnggaranWaktu
{
    public function __construct(private readonly int $anggaranDetik)
    {
    }

    public function cukupUntukSatuPercobaanLagi(int $terpakaiDetik, int $batasWaktuPercobaan): bool
    {
        return $terpakaiDetik + $batasWaktuPercobaan <= $this->anggaranDetik;
    }
}

$anggaran = new AnggaranWaktu(30);
$batasWaktuPercobaan = 8;

$riwayatPercobaan = [
    ["percobaan" => 1, "selesaiPadaDetik" => 6],
    ["percobaan" => 2, "selesaiPadaDetik" => 15],
    ["percobaan" => 3, "selesaiPadaDetik" => 29],
];

foreach ($riwayatPercobaan as $baris) {
    $nomor = $baris["percobaan"];
    $terpakai = $baris["selesaiPadaDetik"];
    $cukup = $anggaran->cukupUntukSatuPercobaanLagi($terpakai, $batasWaktuPercobaan);
    $keputusan = $cukup
        ? "anggaran cukup, jadwalkan retry"
        : "anggaran tidak cukup, berhenti dan catat ke dead-letter";
    echo "Setelah percobaan {$nomor} (terpakai {$terpakai} detik): {$keputusan}\n";
}

Output yang Dihasilkan:

text
Setelah percobaan 1 (terpakai 6 detik): anggaran cukup, jadwalkan retry
Setelah percobaan 2 (terpakai 15 detik): anggaran cukup, jadwalkan retry
Setelah percobaan 3 (terpakai 29 detik): anggaran tidak cukup, berhenti dan catat ke dead-letter

Logikanya berjalan seperti ini:

  1. cukupUntukSatuPercobaanLagi() membandingkan waktu yang sudah terpakai ditambah perkiraan durasi satu percobaan berikutnya terhadap anggaran total — job hanya boleh retry jika seluruh percobaan berikutnya masih masuk anggaran.
  2. Setelah percobaan 1 dan 2, sisa anggaran masih menampung satu percobaan 8 detik (14 ≤ 30 dan 23 ≤ 30), sehingga retry dijadwalkan.
  3. Setelah percobaan 3, 29 + 8 = 37 melebihi 30; worker berhenti dan mencatat job ke dead-letter meskipun batas jumlah percobaan belum tercapai. Anggaran waktu bisa lebih dulu habis daripada jatah percobaan.

Ringkasan #

Kebijakan retry yang sehat terdiri dari jeda eksponensial yang terpotong batas atas, jitter yang menyebarkan beban retry, klasifikasi transien yang menolak mengulang kegagalan permanen, batas percobaan yang berujung dead-letter, dan dua lapis timeout — per percobaan dan total. Seluruh keputusan ditulis sebagai fungsi murni dengan waktu dan keacakan yang disuntikkan, sehingga semuanya terbukti secara deterministik lewat unit test tanpa menunggu timer sungguhan. Namun ada satu asumsi yang selama ini belum kita periksa: runner kita dengan senang hati menjalankan ulang job yang efek sampingnya belum tentu aman diulang. Chapter berikutnya meluruskan asumsi itu lewat desain job yang idempoten.

Referensi #