โ† Semua pembelajaran / Astro Nol โ†’ Portal Berita
Fase 3 ยท Jalur API

Retry, backoff & circuit breaker

Saat layanan hulu melambat, retry naif dari ribuan pembaca akan menggandakan bebannya tepat saat ia paling tidak sanggup. Ini cara melakukannya tanpa memperburuk keadaan.

Sumber asli aws.amazon.com Resmi Rangkuman ~9 menit baca

Intisari

  • Hanya idempoten yang boleh diulang. GET ya; POST pembayaran tidak.
  • Backoff harus eksponensial dan ber-jitter. Tanpa jitter, semua klien mengulang pada detik yang sama.
  • Retry punya anggaran. Tiga percobaan, dan total waktunya harus muat di dalam timeout halamanmu.
  • Circuit breaker berhenti mencoba setelah sekian kegagalan berturut-turut โ€” melindungi hulu dan membebaskan container-mu.
  • Degradasi anggun mengalahkan retry: halaman tanpa widget kurs lebih baik daripada halaman yang menunggu 9 detik.

Kenapa retry naif memperparah keadaan

API kurs melambat dari 100 ms jadi 3 detik.

Tanpa retry:   1.000 request/detik  โ†’  API menerima 1.000/detik
Dengan 3 retry: 1.000 request/detik โ†’  API menerima 4.000/detik

Layanan yang sedang kepayahan sekarang menerima empat kali lipat.
Ia tidak akan pulih. Ini disebut retry storm.

Ditambah efek kedua yang lebih dekat ke rumah: tiap request yang mengulang menahan satu koneksi dan satu slot di event loop container-mu selama 9 detik alih-alih 3. Kapasitasmu sendiri ikut habis.

Apa yang boleh diulang

OperasiBoleh diulang?Alasan
GET apa punYaIdempoten menurut definisi
PUT, DELETEYaIdempoten menurut definisi
POST membuat langgananTidak, kecuali ada kunci idempotensiBisa jadi dua langganan, dua tagihan
Kueri SELECTYaโ€”
Transaksi yang kena deadlockYaSudah ter-rollback penuh (Fase 2)
Timeout tanpa responsHati-hatiKamu tidak tahu apakah sisi sana sudah mengerjakannya

Baris terakhir itu yang paling berbahaya di alur pembayaran. Timeout bukan berarti gagal โ€” ia berarti tidak tahu. Midtrans mungkin sudah menerima dan memproses permintaanmu; yang hilang cuma jawabannya. Mengulanginya bisa berarti pembeli ditagih dua kali. Satu-satunya cara aman adalah kunci idempotensi, yang dibahas di Fase 6.

Retry dengan backoff dan jitter

// src/lib/ulang.ts
export async function denganUlang<T>(
  fn: (percobaan: number) => Promise<T>,
  opsi: { maks?: number; dasarMs?: number; totalMs?: number } = {},
): Promise<T> {
  const { maks = 3, dasarMs = 100, totalMs = 2500 } = opsi;
  const batasWaktu = Date.now() + totalMs;
  let terakhir: unknown;

  for (let i = 0; i < maks; i++) {
    try {
      return await fn(i);
    } catch (e) {
      terakhir = e;

      if (!layakDiulang(e) || i === maks - 1) break;

      // Eksponensial: 100, 200, 400 โ€” dengan jitter penuh.
      const tunggu = Math.random() * dasarMs * 2 ** i;

      if (Date.now() + tunggu > batasWaktu) break;   // anggaran habis
      await new Promise((r) => setTimeout(r, tunggu));
    }
  }
  throw terakhir;
}

function layakDiulang(e: unknown): boolean {
  if (e instanceof GalatApi) {
    if (e.status === undefined) return true;              // jaringan
    if (e.status === 429) return true;                    // rate limit
    if (e.status >= 500 && e.status !== 501) return true;  // gangguan hulu
    return false;                                          // 4xx: salahmu
  }
  return e instanceof Error && e.name === "TimeoutError";
}

Math.random() * dasar * 2 ** i โ€” jitter penuh, bukan setengah. Kalau semua klien menunggu tepat 100 ms lalu 200 ms, mereka mengulang pada detik yang sama dan gelombangnya utuh. Jitter penuh menyebarkan percobaan ke seluruh rentang, dan itu yang memecah gelombangnya. Ini rekomendasi eksplisit dari Amazon Builders' Library, dan bedanya terukur.

Perhatikan juga totalMs: anggaran waktu keseluruhan. Tiga percobaan tidak berguna kalau halamanmu sudah menyerah di detik ketiga. Anggaran retry harus muat di dalam timeout halaman, yang muat di dalam timeout ALB.

Jangan mengulang 4xx

Status 400, 401, 403, 404, dan 422 berarti permintaanmu yang salah. Mengulanginya persis sama akan menghasilkan jawaban yang persis sama โ€” kamu hanya membuang waktu dan menambah beban. Satu-satunya pengecualian adalah 429, yang berarti "coba lagi nanti" secara harfiah. Kalau ada header Retry-After, hormati angkanya alih-alih memakai backoff-mu sendiri.

Circuit breaker

// src/lib/breaker.ts
type Status = "tertutup" | "terbuka" | "setengah";

export function buatBreaker(opsi = { ambang: 5, jedaMs: 30_000 }) {
  let status: Status = "tertutup";
  let gagal = 0;
  let bukaSampai = 0;

  return async function jalankan<T>(fn: () => Promise<T>): Promise<T> {
    if (status === "terbuka") {
      if (Date.now() < bukaSampai) {
        throw new GalatApi("sirkuit terbuka");
      }
      status = "setengah";   // izinkan satu percobaan
    }

    try {
      const hasil = await fn();
      gagal = 0;
      status = "tertutup";
      return hasil;
    } catch (e) {
      gagal++;
      if (status === "setengah" || gagal >= opsi.ambang) {
        status = "terbuka";
        bukaSampai = Date.now() + opsi.jedaMs;
        console.warn(JSON.stringify({ level: "warn", pesan: "sirkuit terbuka", gagal }));
      }
      throw e;
    }
  };
}
StatusPerilaku
TertutupNormal. Hitung kegagalan berturut-turut.
TerbukaGagal seketika tanpa memanggil hulu. Melindungi keduanya.
SetengahIzinkan satu percobaan. Sukses โ†’ tertutup; gagal โ†’ terbuka lagi.

Nilai terbesar breaker bukan melindungi layanan hulu โ€” itu efek samping yang baik. Nilainya adalah membebaskan container-mu. Saat sirkuit terbuka, halamanmu gagal dalam mikrodetik alih-alih menunggu 3 detik, sehingga event loop-mu tetap lega untuk melayani semua halaman lain yang tidak bergantung pada API itu.

const breakerKurs = buatBreaker();

export const ambilKurs = () =>
  cache("kurs", 300, () =>
    breakerKurs(() => denganUlang(() => ambilJson<Kurs>(URL_KURS, { timeoutMs: 2000 }))),
  );

Urutan pembungkusnya disengaja, dari luar ke dalam: cache โ†’ breaker โ†’ retry โ†’ fetch. Cache paling luar supaya hit tidak menyentuh apa pun. Breaker di atas retry supaya sirkuit terbuka membatalkan seluruh rangkaian percobaan, bukan hanya satu.

Yang mengalahkan semuanya: degradasi anggun

KomponenKalau sumbernya mati
Widget kursSembunyikan seluruhnya
Berita terpopulerTampilkan daftar terbaru dari database
Data pasarTampilkan snapshot terakhir + "per pukul 14.30"
Rekomendasi personalTampilkan artikel populer umum
Status langgananJangan menebak โ€” tampilkan "coba muat ulang"
Isi artikelTidak ada degradasi. Ini alasan halamannya ada.

Baris "status langganan" itu keputusan produk, bukan keputusan teknis, dan harus dibuat sadar. Kalau layanan langganan mati: apakah kamu membuka paywall untuk semua orang (kehilangan pendapatan) atau menutupnya untuk semua orang (member yang membayar marah)? Jawaban yang biasanya benar untuk portal berita adalah pilihan ketiga: tampilkan artikel penuh dengan catatan kecil โ€” pembaca yang tidak berlangganan mendapat satu artikel gratis, member yang membayar tidak dirugikan, dan gangguannya tidak jadi krisis layanan pelanggan.

Latihan: bungkus satu panggilan API dengan ketiga lapis: cache, breaker, retry. Buat API palsu yang gagal lima kali lalu pulih, dan catat waktu tiap percobaan. Verifikasi tiga hal: jeda antar percobaan naik dan tidak seragam; setelah lima kegagalan sirkuit terbuka dan panggilan berikutnya gagal seketika tanpa menyentuh API; dan tiga puluh detik kemudian ia mencoba lagi.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.