Pengiriman email hampir tidak pernah bebas kegagalan: server penerima sedang sibuk, kotak surat penuh, atau alamat email salah ketik sejak awal. Tanpa strategi yang jelas, aplikasi hanya punya dua pilihan buruk — mengabaikan setiap kegagalan atau mengulang kiriman secara membabi buta. Chapter ini membangun kerangka berpikir yang lebih tertata: mengklasifikasikan kegagalan berdasarkan kode SMTP, menjadwalkan retry dengan exponential backoff, menghentikan upaya pada batas percobaan tertentu (dead letter), lalu menangani bounce hard dan soft. Semua contoh memakai data tetap dan waktu beku sehingga seluruh logika dapat diuji tanpa SMTP server nyata.
Taksonomi Kegagalan Pengiriman #
Kegagalan pengiriman tidak seragam, dan perlakuan yang benar bergantung pada jenisnya. Server penerima menjawab setiap tahap percakapan SMTP dengan kode numerik: kelas 2xx berarti sukses, kelas 4xx berarti gagal sementara (coba lagi nanti), dan kelas 5xx berarti gagal permanen (jangan diulang). Tabel berikut merangkum kode yang paling sering muncul:
| Kode SMTP | Arti umum | Kategori | Tindakan worker |
|---|---|---|---|
| 250 | Permintaan diterima | Sukses | Tandai job done |
| 421 | Service tidak tersedia sementara | Sementara | Jadwalkan retry |
| 450 | Kotak surat sibuk / greylisting | Sementara | Jadwalkan retry |
| 550 | Kotak surat tidak tersedia | Permanen | Pindahkan ke dead letter |
| 553 | Alamat penerima ditolak | Permanen | Pindahkan ke dead letter |
Klasifikasi tersebut mudah diterjemahkan menjadi function PHP dengan expression match:
<?php
function classifySmtpResponse(int $code): string
{
return match (true) {
$code >= 500 => "permanen",
$code >= 400 => "sementara",
default => "sukses",
};
}
$contohRespons = [250, 421, 450, 550, 553];
foreach ($contohRespons as $code) {
echo "SMTP {$code} => " . classifySmtpResponse($code) . "\n";
}Output yang Dihasilkan:
SMTP 250 => sukses
SMTP 421 => sementara
SMTP 450 => sementara
SMTP 550 => permanen
SMTP 553 => permanenExpression match (true) mengevaluasi setiap kondisi dari atas ke bawah dan mengembalikan lengan pertama yang bernilai true. Karena kondisi $code >= 500 diperiksa lebih dulu, kode 553 tidak pernah sampai ke lengan $code >= 400. Pemisahan sementara versus permanen inilah yang menentukan seluruh keputusan berikutnya: kegagalan sementara layak di-retry, sedangkan kegagalan permanen hanya membuang sumber daya bila diulang.
Retry dengan Exponential Backoff #
Mengulang kiriman setiap sepuluh detik setelah kegagalan bukan strategi yang baik. Server penerima yang sedang overload justru semakin tertekan, dan queue Anda dipenuhi job yang gagal berulang. Exponential backoff menyelesaikan kedua masalah tersebut: jeda antar-percobaan tumbuh secara eksponensial, sehingga percobaan pertama datang cepat (mungkin gangguan hanya sesaat) sementara percobaan berikutnya makin jarang.
Program berikut menghitung jadwal retry untuk tiga percobaan dengan basis 60 detik dan faktor pertumbuhan 3 — menghasilkan jeda 60, 180, lalu 540 detik:
<?php
function nextAttemptAt(DateTimeImmutable $failedAt, int $attempt): DateTimeImmutable
{
$delaySeconds = 60 * (3 ** ($attempt - 1));
return $failedAt->add(new DateInterval("PT{$delaySeconds}S"));
}
$waktu = new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta"));
for ($attempt = 1; $attempt <= 3; $attempt++) {
$jadwalBerikutnya = nextAttemptAt($waktu, $attempt);
echo "Percobaan {$attempt} gagal pukul " . $waktu->format("H:i:s")
. " - coba lagi pukul " . $jadwalBerikutnya->format("H:i:s") . "\n";
$waktu = $jadwalBerikutnya;
}Output yang Dihasilkan:
Percobaan 1 gagal pukul 08:00:00 - coba lagi pukul 08:01:00
Percobaan 2 gagal pukul 08:01:00 - coba lagi pukul 08:04:00
Percobaan 3 gagal pukul 08:04:00 - coba lagi pukul 08:13:00Function nextAttemptAt() menerima waktu kegagalan sebagai parameter alih-alih memanggil date() atau time() sendiri. Desain ini membuat perhitungan murni dan mudah diuji — Anda bebas memasukkan waktu mana pun, termasuk waktu beku untuk pengujian. Hasil penjumlahan DateInterval dikembalikan sebagai DateTimeImmutable baru sehingga nilai waktu lama tidak pernah berubah.
Simulasi Lengkap sampai Dead Letter #
Retry tanpa batas adalah jebakan: job yang tidak akan pernah sukses tetap hidup di queue selamanya. Karena itu setiap job diberi batas percobaan maksimum (maxAttempts); begitu batas terlampaui, job dipindahkan ke tabel failed seperti yang kita bangun pada chapter Email Queueing
. Simulasi berikut merangkai seluruh alurnya dengan FakeSmtpClient yang jawabannya sudah ditentukan di muka:
<?php
class FakeSmtpClient
{
private int $panggilan = 0;
public function __construct(private readonly array $responses)
{
}
public function send(string $recipient, string $subject): int
{
$code = $this->responses[$this->panggilan];
$this->panggilan++;
return $code;
}
}
function classifySmtpResponse(int $code): string
{
return match (true) {
$code >= 500 => "permanen",
$code >= 400 => "sementara",
default => "sukses",
};
}
$smtp = new FakeSmtpClient([421, 450, 421]);
$maxAttempts = 3;
$waktu = new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta"));
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$code = $smtp->send("[email protected]", "Invoice #1042");
$hasil = classifySmtpResponse($code);
echo "Percobaan {$attempt} ({$waktu->format('H:i:s')}): SMTP {$code} - {$hasil}\n";
if ($hasil === "permanen") {
echo " -> dead letter: kegagalan permanen tidak di-retry\n";
break;
}
if ($attempt === $maxAttempts) {
echo " -> dead letter: batas {$maxAttempts} percobaan tercapai\n";
break;
}
$delaySeconds = 60 * (3 ** ($attempt - 1));
$waktu = $waktu->add(new DateInterval("PT{$delaySeconds}S"));
echo " -> retry terjadwal pukul " . $waktu->format("H:i:s") . "\n";
}
echo "--- Kasus berbeda: kegagalan permanen ---\n";
$smtpPermanen = new FakeSmtpClient([550]);
$code = $smtpPermanen->send("[email protected]", "Promo Akhir Pekan");
echo "Percobaan 1: SMTP {$code} - " . classifySmtpResponse($code) . "\n";
echo " -> dead letter langsung tanpa retry\n";Output yang Dihasilkan:
Percobaan 1 (08:00:00): SMTP 421 - sementara
-> retry terjadwal pukul 08:01:00
Percobaan 2 (08:01:00): SMTP 450 - sementara
-> retry terjadwal pukul 08:04:00
Percobaan 3 (08:04:00): SMTP 421 - sementara
-> dead letter: batas 3 percobaan tercapai
--- Kasus berbeda: kegagalan permanen ---
Percobaan 1: SMTP 550 - permanen
-> dead letter langsung tanpa retryMari kita bedah alur simulasi tersebut:
FakeSmtpClientmenyimpan daftar kode jawaban pada property$responsesdan mengembalikannya satu per satu setiap kalisend()dipanggil. Kelas pengganti (test double) semacam ini memungkinkan logika retry diuji penuh tanpa jaringan.- Loop utama menjalankan percobaan sampai
maxAttempts. Setiap jawaban diklasifikasikan lebih dulu; kegagalan permanen langsung memutus loop denganbreakkarena percobaan berikutnya pasti sia-sia. - Penjadwalan ulang menaikkan
$waktusesuai rumus backoff yang sama dengan contoh sebelumnya. Pada percobaan ketiga, batas tercapai sehingga job berakhir di dead letter, bukan dijadwalkan ulang. - Kasus kedua membuktikan jalur cepat: satu jawaban
550langsung mengirim job ke dead letter tanpa retry sama sekali.
Bounce: Hard versus Soft #
Bounce adalah laporan kegagalan yang dikirim balik oleh server penerima setelah percakapan SMTP selesai — berbeda dari penolakan saat percakapan yang baru kita simulasikan. Bounce terbagi dua kategori yang perlakuannya bertolak belakang. Hard bounce berarti alamat tidak akan pernah bisa menerima email (pengguna tidak ada, domain tidak ada), sedangkan soft bounce berarti hambatan sesaat (kotak surat penuh, server greylisting, timeout jaringan). Klasifikasi berikut memeriksa pola teks pada laporan bounce:
<?php
function classifyBounce(string $reason): string
{
$hardPatterns = ["user unknown", "no such user", "domain not found", "invalid mailbox"];
$softPatterns = ["mailbox full", "greylisted", "timeout", "temporarily unavailable"];
foreach ($hardPatterns as $pattern) {
if (str_contains($reason, $pattern)) {
return "hard";
}
}
foreach ($softPatterns as $pattern) {
if (str_contains($reason, $pattern)) {
return "soft";
}
}
return "tidak diketahui";
}
function shouldRetryBounce(string $type, int $attempts, int $maxAttempts): bool
{
if ($type === "hard") {
return false;
}
return $type === "soft" && $attempts < $maxAttempts;
}
$laporanBounce = [
"550 5.1.1 user unknown",
"550 5.1.10 domain not found",
"452 4.2.2 mailbox full",
"421 4.7.0 greylisted, coba lagi nanti",
];
foreach ($laporanBounce as $reason) {
$tipe = classifyBounce($reason);
$retry = shouldRetryBounce($tipe, 1, 3) ? "ya" : "tidak";
echo "{$reason} -> {$tipe} bounce, retry: {$retry}\n";
}Output yang Dihasilkan:
550 5.1.1 user unknown -> hard bounce, retry: tidak
550 5.1.10 domain not found -> hard bounce, retry: tidak
452 4.2.2 mailbox full -> soft bounce, retry: ya
421 4.7.0 greylisted, coba lagi nanti -> soft bounce, retry: yaFunction classifyBounce() memeriksa pola hard lebih dulu karena konsekuensinya lebih berat; function shouldRetryBounce() kemudian menerjemahkan klasifikasi menjadi keputusan: hard bounce selalu false, soft bounce boleh di-retry selama batas percobaan belum tercapai. Dua laporan 550 di atas terdeteksi sebagai hard, dan dua laporan lainnya sebagai soft yang masih layak diulang.
Memberi Tahu Manusia saat Dead Letter #
Tabel failed dan suppression list adalah tempat kegagalan berakhir, tetapi kegagalan yang menumpuk diam-diam tetap berbahaya — invoice yang tidak pernah terkirim adalah masalah bisnis, bukan sekadar baris tabel. Sistem yang matang karena itu menambahkan lapisan alerting: ketika job mencapai dead letter, aplikasi memberi tahu manusia yang bertanggung jawab. Bentuk paling sederhana adalah function yang mengirim ringkasan ke kanal on-call:
<?php
function notifyOnDeadLetter(array $job, string $reason): void
{
$pesan = "Job email #{$job['id']} untuk {$job['recipient']} gagal permanen: {$reason}";
// Di production: kirim ke kanal on-call (mis. webhook Slack) atau
// email ops, lalu catat ke sistem monitoring.
error_log($pesan);
}Function di atas hanya menuliskan pesan ke error log; pada sistem nyata, isi block komentar digantikan panggilan webhook atau API monitoring. Yang penting bukan kanalnya, melainkan keberadaannya: kegagalan permanen harus terlihat oleh manusia dalam hitungan menit, bukan ditemukan minggu kemudian saat pelanggan komplain.
Ringkasan #
Strategi menghadapi kegagalan pengiriman berdiri di atas tiga keputusan: klasifikasi (4xx sementara, 5xx permanen), penjadwalan (exponential backoff dengan waktu yang diinjeksi agar mudah diuji), dan penghentian (batas percobaan berujung dead letter). Bounce melengkapi gambaran dengan aturan kerasnya — hard bounce tidak pernah di-retry — sementara alerting memastikan kegagalan permanen sampai ke manusia. Seluruh mekanisme ini menjawab pertanyaan “bagaimana mengirim ulang dengan benar”; pertanyaan yang lebih mendasar — apakah pengguna ingin menerima notifikasi tersebut sama sekali — dibahas pada chapter Notification Preferences .