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.
Intisari
- Hanya idempoten yang boleh diulang.
GETya;POSTpembayaran 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
| Operasi | Boleh diulang? | Alasan |
|---|---|---|
GET apa pun | Ya | Idempoten menurut definisi |
PUT, DELETE | Ya | Idempoten menurut definisi |
POST membuat langganan | Tidak, kecuali ada kunci idempotensi | Bisa jadi dua langganan, dua tagihan |
Kueri SELECT | Ya | โ |
| Transaksi yang kena deadlock | Ya | Sudah ter-rollback penuh (Fase 2) |
| Timeout tanpa respons | Hati-hati | Kamu 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;
}
};
}
| Status | Perilaku |
|---|---|
| Tertutup | Normal. Hitung kegagalan berturut-turut. |
| Terbuka | Gagal seketika tanpa memanggil hulu. Melindungi keduanya. |
| Setengah | Izinkan 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
| Komponen | Kalau sumbernya mati |
|---|---|
| Widget kurs | Sembunyikan seluruhnya |
| Berita terpopuler | Tampilkan daftar terbaru dari database |
| Data pasar | Tampilkan snapshot terakhir + "per pukul 14.30" |
| Rekomendasi personal | Tampilkan artikel populer umum |
| Status langganan | Jangan menebak โ tampilkan "coba muat ulang" |
| Isi artikel | Tidak 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.