Saat Siti menekan tombol bayar, server toko buku kebetulan sedang kelebihan beban dan menjawab 503 Service Unavailable. Menampilkan pesan galat seketika merugikan dua pihak: pengguna kehilangan transaksi, padahal server hanya butuh beberapa detik untuk pulih. Permintaan semacam ini pantas dicoba ulang (retry) — tetapi dengan strategi, bukan dengan pengulangan buta yang membanjiri server yang sudah sibuk. Chapter ini merancang strateginya: kapan boleh mencoba ulang, jeda berapa lama di antara percobaan, dan kapan permintaan perlu diberi batas waktu.

Kedua berkas contoh memakai server tiruan yang sengaja digarap seperti itu: satu endpoint gagal dua kali lalu pulih, satu lagi lambat melewati batas waktu pada percobaan pertama. Jalankan node coba-ulang-backoff.mjs dan node batas-waktu-percobaan.mjs untuk mereproduksi keluarannya.

Tiga Aturan Main Retry #

Pengulangan yang salah memperberat server yang sedang susah. Tiga aturan menjaganya tetap sehat:

  1. Hanya kegagalan sementara yang diulang. Status 5xx (terutama 503) dan kegagalan jaringan seperti TimeoutError layak dicoba lagi karena kondisinya bisa pulih. Sebaliknya, 4xx menandakan permintaan itu sendiri salah — permintaan identik berikutnya akan gagal dengan cara yang sama, jadi jangan diulang.
  2. Beri jeda antar percobaan (backoff). Percobaan yang menyusul tanpa jeda hanya menambah tekanan. Jeda yang lazim bertumbuh eksponensial: percobaan kedua menunggu dua kali lebih lama dari yang pertama, percobaan ketiga dua kali lagi.
  3. Tetapkan batas percobaan (max attempt). Tanpa batas, loop retry bisa berjalan tanpa henti. Tiga sampai lima percobaan adalah titik berhenti yang wajar; setelah itu lemparkan galat agar penanganan di lapisan atas bekerja.

Jeda eksponensial ini disebut exponential backoff. Rumus umumnya jedaAwal * 2 ** (percobaan - 1) — dari jeda awal 10 ms menghasilkan 10, 20, 40 ms, dan seterusnya. Contoh kita memakai angka mini agar keluaran cepat teruji; sistem nyata biasanya memulai dari ratusan milidetik. Implementasi produksi sering menambahkan jitter — pengacakan kecil pada jeda — supaya ribuan client tidak menyerang server di detik yang sama; konsepnya di luar cakupan contoh ini.

Satu penyetelan yang murah namun berdampak: hormati jawaban server. Status 503 atau 429 sering datang dengan header Retry-After yang mengajukan jeda tunggu — bila server berkata “tunggu 30 detik”, mengabaikannya dan mencoba 100 ms kemudian justru menandakan client yang tak bisa diajak sepakat. Bila header itu ada, pakai; bila tidak, rumus backoff di atas yang memutuskan. Dalam loop pagination dari chapter sebelumnya, pola yang lazim adalah membungkus fetch tiap halaman dengan helper retry — satu halaman gagal sementara tak perlu membatalkan penelusuran seluruh katalog.

Retry dengan Backoff terhadap Server yang Pulih #

Berkas berikut menuntut dua kasus sekaligus: server yang gagal dua kali lalu pulih, dan server yang tidak pernah pulih — untuk membuktikan batas percobaan benar-benar berhenti:

// coba-ulang-backoff.mjs
// Pola retry dengan backoff eksponensial terhadap server tiruan yang gagal 2x.
// Jalankan di terminal: node coba-ulang-backoff.mjs (tanpa internet).

import http from "node:http";

// Server tiruan: /bayar gagal 2x (503) lalu pulih; /selalu-gagal selalu 503.
let gagalBerapa = 0;
const server = http.createServer((req, res) => {
  if (req.url === "/bayar") {
    if (gagalBerapa < 2) {
      gagalBerapa++;
      res.writeHead(503, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ pesan: "sedang sibuk, coba lagi" }));
    } else {
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ status: "LUNAS", id: "INV-092" }));
    }
    return;
  }
  res.writeHead(503, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ pesan: "server tidak pulih" }));
});

const tunggu = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// fetchDenganRetry: mencoba hingga maxPercobaan kali, jeda menggandakan diri.
async function fetchDenganRetry(url, { maxPercobaan = 3, jedaAwal = 10 } = {}) {
  for (let percobaan = 1; percobaan <= maxPercobaan; percobaan++) {
    const response = await fetch(url);
    if (response.ok) return response;

    const gagalTerakhir = percobaan === maxPercobaan;
    console.log(`percobaan ${percobaan}: status ${response.status} — ${gagalTerakhir ? "berhenti" : "akan diulang"}`);
    if (gagalTerakhir) break;
    const jeda = jedaAwal * 2 ** (percobaan - 1); // 10, 20, 40, ... ms
    console.log(`menunggu ${jeda} ms...`);
    await tunggu(jeda);
  }
  throw new Error(`gagal setelah ${maxPercobaan} percobaan`);
}

server.listen(0, "127.0.0.1", async () => {
  const port = server.address().port;
  const dasar = `http://127.0.0.1:${port}`;

  // Kasus 1: server pulih pada percobaan ketiga.
  const pulih = await fetchDenganRetry(`${dasar}/bayar`);
  console.log("sukses pulih:", pulih.status, "-", JSON.stringify(await pulih.json()));

  // Kasus 2: server tidak pernah pulih — galat dilempar setelah batas percobaan.
  try {
    await fetchDenganRetry(`${dasar}/selalu-gagal`);
  } catch (error) {
    console.log("tertangkap:", error.message);
  }

  server.closeAllConnections();
  server.close();
});

Output yang Dihasilkan:

text
percobaan 1: status 503 — akan diulang
menunggu 10 ms...
percobaan 2: status 503 — akan diulang
menunggu 20 ms...
sukses pulih: 200 - {"status":"LUNAS","id":"INV-092"}
percobaan 1: status 503 — akan diulang
menunggu 10 ms...
percobaan 2: status 503 — akan diulang
menunggu 20 ms...
percobaan 3: status 503 — berhenti
tertangkap: gagal setelah 3 percobaan

Mari kita telaah baris-baris pentingnya:

  1. Baris 8 dan 11–14 (server yang gagal terjadwal): variabel gagalBerapa membuat endpoint /bayar menjawab 503 untuk dua permintaan pertama, lalu 200 setelahnya — perilaku server yang pulih, yang tidak mungkin diuji dengan internet sungguhan secara deterministik.
  2. Baris 29–31 (loop percobaan): for berjalan maksimal maxPercobaan kali. Begitu response.ok bernilai true, function langsung mengembalikan respons sukses — percobaan sisa tidak pernah berjalan.
  3. Baris 33–35 (deteksi putus): gagalTerakhir memisahkan dua nasib: percobaan yang masih punya kesempatan dicatat “akan diulang”, sedangkan percobaan terakhir dicatat “berhenti” lalu keluar dari loop.
  4. Baris 36 (jedaAwal * 2 ** (percobaan - 1)): inti backoff eksponensial — pangkat yang naik bersama nomor percobaan menghasilkan 10, 20, 40 ms. Operator ** dari section Operators bekerja di sini.
  5. Baris 38 (await tunggu(jeda)): jeda menunggu sungguhan; tunggu adalah pembungkus setTimeout dalam Promise sehingga bisa di-await di dalam function async.
  6. Baris 40 (galat akhir): setelah batas percobaan habis, function melempar Error dengan pesan ringkas. Kegagalan jadi eksplisit dan tertangani — keluaran kasus kedua membuktikannya: tertangkap: gagal setelah 3 percobaan.

Perhatikan juga keluaran kasus pertama: tidak ada baris “percobaan 3” untuk /bayar, karena percobaan ketiga langsung sukses dan return di baris 31 mengakhiri semuanya.

Inilah alasan mock server seperti ini bernilai: perilaku “gagal dua kali lalu pulih” nyaris mustahil direproduksi secara deterministik terhadap API sungguhan, tetapi dengan variabel penghitung sederhana, pola retry kita teruji berulang kali dengan keluaran yang sama — persis filosofi pengujian yang dipakai sepanjang section ini.

Batas Waktu per Percobaan #

Retry tanpa batas waktu punya celah: percobaan yang menggantung tanpa jawaban akan memegang loop berjam-jam. Gabungan yang sehat menyatukan dua chapter sebelumnya — tiap percobaan diberi sinyal AbortSignal.timeout, dan TimeoutError diperlakukan sebagai kegagalan sementara yang layak dicoba lagi:

// batas-waktu-percobaan.mjs
// Retry + timeout: tiap percobaan diberi batas waktu lewat AbortSignal.timeout.
// Jalankan di terminal: node batas-waktu-percobaan.mjs (tanpa internet).

import http from "node:http";

// Server tiruan: permintaan pertama ke /ambil berjalan lambat, selanjutnya cepat.
let sudahDitanya = 0;
const server = http.createServer((req, res) => {
  if (req.url === "/ambil") {
    if (sudahDitanya === 0) {
      sudahDitanya++;
      setTimeout(() => res.end('{"data":"katalog lengkap"}'), 400); // melewati batas 50 ms
      return;
    }
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end('{"data":"katalog lengkap"}');
    return;
  }
  res.writeHead(404);
  res.end();
});

const tunggu = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function fetchDenganRetry(url, { maxPercobaan = 3, batasWaktu = 50, jedaAwal = 10 } = {}) {
  for (let percobaan = 1; percobaan <= maxPercobaan; percobaan++) {
    try {
      const response = await fetch(url, { signal: AbortSignal.timeout(batasWaktu) });
      if (response.ok) return response;
      console.log(`percobaan ${percobaan}: status ${response.status}`);
    } catch (error) {
      // TimeoutError berarti percobaan ini melebihi batas waktu — layak diulang.
      console.log(`percobaan ${percobaan}: ${error.name} — batas ${batasWaktu} ms terlampaui`);
    }
    if (percobaan < maxPercobaan) {
      const jeda = jedaAwal * 2 ** (percobaan - 1);
      console.log(`menunggu ${jeda} ms...`);
      await tunggu(jeda);
    }
  }
  throw new Error(`gagal setelah ${maxPercobaan} percobaan`);
}

server.listen(0, "127.0.0.1", async () => {
  const port = server.address().port;
  const response = await fetchDenganRetry(`http://127.0.0.1:${port}/ambil`);
  console.log("sukses:", response.status, "-", JSON.stringify(await response.json()));

  server.closeAllConnections();
  server.close();
});

Output yang Dihasilkan:

text
percobaan 1: TimeoutError — batas 50 ms terlampaui
menunggu 10 ms...
sukses: 200 - {"data":"katalog lengkap"}

Mari kita telaah baris-baris pentingnya:

  1. Baris 11–14 (lambat hanya sekali): server tiruan menjawab permintaan pertama setelah 400 ms — di luar batas 50 ms — dan responsnya nyaris seketika untuk permintaan berikutnya, sehingga cerita retry-nya deterministik.
  2. Baris 29 ({ signal: AbortSignal.timeout(batasWaktu) }): tiap percobaan memperoleh sinyal waktunya sendiri — batas berlaku per percobaan, bukan untuk seluruh rangkaian.
  3. Baris 32–34 (galat waktu = sinyal diulang): TimeoutError tertangkap, dicatat, dan loop melanjutkan ke percobaan berikutnya alih-alih gagal total. Inilah bedanya dengan AbortError buatan pengguna, yang biasanya berarti “berhenti, jangan diulang”.
  4. Baris 36 (if (percobaan < maxPercobaan)): jeda hanya dijalankan bila masih ada percobaan sisa — tidak ada penantian sia-sia setelah putaran terakhir.

Strategi retry menuntaskan ketahanan sisi client. Pertanyaan yang tersisa: ketika semua percobaan gagal, bagaimana menerjemahkan respons galat server menjadi sesuatu yang bermakna bagi pengguna? Itulah topik penutup section ini — API Error Handling.