Kegagalan paling menyulitkan di sistem terdistribusi bukan error yang jelas, melainkan panggilan yang tidak pernah selesai: dependensi menerima permintaan lalu diam, dan thread pemanggil menunggu tanpa kata putus. Tiga pola pada chapter ini bekerja pada satu sumbu — waktu. Timeout memberi tiap panggilan batas umur. Retry memberi kegagalan transien kesempatan kedua dengan jeda membesar. Circuit breaker menghitung kegagalan beruntun lalu memutus jalur seluruhnya, memberi dependensi yang sakit kesempatan pulih. Ketiganya kita bangun dalam bentuk mini yang bisa dijalankan — tanpa jaringan, tanpa waktu nyata.

Timeout sebagai fondasi #

Tanpa timeout, dua pola lain kehilangan makna: retry tidak pernah terpicu karena percobaan pertama belum selesai, dan circuit breaker tidak pernah menghitung karena kegagalan tidak pernah dilaporkan. Pada java.net.http.HttpRequest, batas waktu dipasang saat membangun request:

java PanggilanBatasWaktu.java — potongan pemasangan timeout
HttpRequest permintaan = HttpRequest.newBuilder()
        .uri(URI.create("http://svc-inventori.internal/api/stok/A1"))
        .timeout(Duration.ofSeconds(2))   // lempar HttpTimeoutException bila lewat
        .GET()
        .build();

Bila batas 2 detik terlampaui, send(...) melempar HttpTimeoutException — turunan IOException — dan thread pemanggil dibebaskan untuk memilih jalur lain. Nilai timeout yang sehat berbeda per dependensi — panggilan jalur kritis pengguna biasanya 1-3 detik, pekerjaan latar boleh lebih longgar; yang lebih penting dari nilainya adalah keberadaannya.

Retry dengan Exponential Backoff dan Jitter #

Retry yang naif — coba lagi seketika, terus-menerus — justru membebani service yang sedang terseok: seribu klien yang gagal bersamaan menembak ulang serempak (thundering herd). Dua bahan meredamnya: exponential backoff (jeda menggandakan diri tiap percobaan) dan jitter (nilai acak kecil yang menyebar momen tembakan). Program berikut menghitung seluruh jeda sebagai nilai murni:

import java.util.Random;

/** Retry dengan exponential backoff dan jitter, seluruh jeda dihitung sebagai nilai murni. */
public class RetryBerjeda {

    /** Dependensi uji: menjawab kegagalan beberapa kali sebelum akhirnya sehat. */
    static class GudangPalsu {
        private final String kegagalan;
        private int sisaKegagalan;

        GudangPalsu(String kegagalan, int sisaKegagalan) {
            this.kegagalan = kegagalan;
            this.sisaKegagalan = sisaKegagalan;
        }

        String ambil() {
            if (sisaKegagalan > 0) {
                sisaKegagalan--;
                return kegagalan;
            }
            return "200";
        }
    }

    /** Hanya kegagalan transien yang layak diulang. */
    static boolean bolehRetry(String status) {
        return status.startsWith("5") || status.equals("408") || status.equals("429");
    }

    public static void main(String[] args) {
        final long dasarMillis = 200;
        final long batasMillis = 1600;
        final int percobaanMaks = 4;

        // Seed tetap agar contoh dapat direproduksi; produksi memakai tanpa seed.
        Random jitter = new Random(42);

        System.out.println("== Skema jeda: min(dasar * 2^(n-1), batas) + jitter 0..100 ms ==");
        for (int n = 1; n <= 4; n++) {
            long eksponensial = Math.min(dasarMillis << (n - 1), batasMillis);
            long tambahan = jitter.nextInt(101);
            System.out.println("  setelah percobaan " + n + " : tunggu "
                    + (eksponensial + tambahan) + " ms  (" + eksponensial
                    + " ms backoff + " + tambahan + " ms jitter)");
        }

        System.out.println();
        System.out.println("== Gudang pulih setelah 2 kegagalan ==");
        jitter = new Random(42); // reset agar urutan jitter sama dengan skema di atas
        GudangPalsu gudang = new GudangPalsu("503", 2);
        for (int n = 1; n <= percobaanMaks; n++) {
            String jawaban = gudang.ambil();
            if (jawaban.equals("200")) {
                System.out.println("  percobaan " + n + " -> 200 : sukses, retry berhenti");
                break;
            }
            if (!bolehRetry(jawaban)) {
                System.out.println("  percobaan " + n + " -> " + jawaban
                        + " : tidak transien, berhenti tanpa retry");
                break;
            }
            if (n == percobaanMaks) {
                System.out.println("  percobaan " + n + " -> " + jawaban
                        + " : batas " + percobaanMaks + " percobaan tercapai, menyerah");
            } else {
                long eksponensial = Math.min(dasarMillis << (n - 1), batasMillis);
                long tambahan = jitter.nextInt(101);
                System.out.println("  percobaan " + n + " -> " + jawaban + " : tunggu "
                        + (eksponensial + tambahan) + " ms lalu coba lagi");
            }
        }

        System.out.println();
        System.out.println("== Permintaan keliru: retry justru memperburuk ==");
        GudangPalsu gudangSalah = new GudangPalsu("400", 5);
        String jawaban = gudangSalah.ambil();
        if (!bolehRetry(jawaban)) {
            System.out.println("  percobaan 1 -> " + jawaban
                    + " : permintaan kita yang keliru, perbaiki pemanggil");
        }
    }
}

Output yang Dihasilkan:

text
== Skema jeda: min(dasar * 2^(n-1), batas) + jitter 0..100 ms ==
  setelah percobaan 1 : tunggu 215 ms  (200 ms backoff + 15 ms jitter)
  setelah percobaan 2 : tunggu 459 ms  (400 ms backoff + 59 ms jitter)
  setelah percobaan 3 : tunggu 804 ms  (800 ms backoff + 4 ms jitter)
  setelah percobaan 4 : tunggu 1689 ms  (1600 ms backoff + 89 ms jitter)

== Gudang pulih setelah 2 kegagalan ==
  percobaan 1 -> 503 : tunggu 215 ms lalu coba lagi
  percobaan 2 -> 503 : tunggu 459 ms lalu coba lagi
  percobaan 3 -> 200 : sukses, retry berhenti

== Permintaan keliru: retry justru memperburuk ==
  percobaan 1 -> 400 : permintaan kita yang keliru, perbaiki pemanggil

Bagian-bagian intinya:

  1. Math.min(dasarMillis << (n - 1), batasMillis): operator shift << menggandakan jeda tiap tingkat — 200, 400, 800, 1600 — dan Math.min menjepitnya di batas atas agar tidak tumbuh tak terbatas. Jeda nyata di produksi dihabiskan dengan Thread.sleep(...).
  2. Random jitter = new Random(42): seed tetap membuat contoh ini dapat direproduksi persis — nilai 15, 59, 4, 89 selalu sama. Di produksi, new Random() tanpa seed (atau jitter berbasis waktu permintaan) yang dipakai agar lalu lintas tersebar alami.
  3. bolehRetry(status): hanya status transien (5xx, 408, 429) yang layak diulang. Skenario ketiga membuktikan bedanya: gudang yang menjawab 400 dihentikan seketika, karena permintaan kita memang keliru — mengulangnya hanya mengulang kesalahan yang sama.
  4. percobaanMaks: retry selalu dibatasi hitungan. Percobaan tak terbatas berarti pemanggil tidak pernah menyerah — dan pengguna menunggu selamanya.

Circuit Breaker State Machine #

Martin Fowler mendefinisikan circuit breaker sebagai objek yang membungkus panggilan dan mengawasi kegagalannya; begitu kegagalan melampaui ambang, panggilan berikutnya gagal cepat tanpa menyentuh dependensi. Nama pola ini dipinjam dari pemutus listrik di rumah. Tiga statusnya membentuk siklus:

Program berikut menggerakkan state machine dengan kejadian eksplisit — gagal(), tick(), sukses() — sehingga seluruh siklus hidupnya dapat disaksikan tanpa jam dinding:

/** Circuit breaker: CLOSED -> OPEN -> HALF_OPEN, digerakkan kejadian eksplisit tanpa waktu nyata. */
public class PemutusSirkit {

    enum Status { CLOSED, OPEN, HALF_OPEN }

    /** State machine circuit breaker dengan ambang kegagalan dan masa tenang berbentuk tick. */
    static class Pemutus {
        private final int ambangKegagalan;
        private final int masaTenangTick;
        private Status status = Status.CLOSED;
        private int kegagalanBeruntun = 0;
        private int tickTersisa = 0;

        Pemutus(int ambangKegagalan, int masaTenangTick) {
            this.ambangKegagalan = ambangKegagalan;
            this.masaTenangTick = masaTenangTick;
        }

        Status status() {
            return status;
        }

        /** Dipanggil sebelum menembak dependensi: apakah diizinkan lewat? */
        boolean bolehPanggil() {
            return status != Status.OPEN;
        }

        void sukses() {
            kegagalanBeruntun = 0;
            if (status == Status.HALF_OPEN) {
                status = Status.CLOSED;
                System.out.println("  sukses : percobaan lolos -> HALF_OPEN menuju CLOSED");
            } else {
                System.out.println("  sukses : kegagalan beruntun direset (tetap CLOSED)");
            }
        }

        void gagal() {
            kegagalanBeruntun++;
            if (status == Status.HALF_OPEN) {
                buka("percobaan gagal");
                return;
            }
            if (kegagalanBeruntun >= ambangKegagalan) {
                buka("kegagalan ke-" + kegagalanBeruntun + " mencapai ambang " + ambangKegagalan);
            } else {
                System.out.println("  gagal  : kegagalan ke-" + kegagalanBeruntun
                        + ", sirkit masih CLOSED");
            }
        }

        /** Satu satuan waktu berlalu; habiskan masa tenang bila sirkit sedang terbuka. */
        void tick() {
            if (status == Status.OPEN) {
                tickTersisa--;
                if (tickTersisa <= 0) {
                    status = Status.HALF_OPEN;
                    System.out.println("  tick   : masa tenang habis -> OPEN menuju HALF_OPEN");
                }
            }
        }

        private void buka(String alasan) {
            status = Status.OPEN;
            tickTersisa = masaTenangTick;
            System.out.println("  gagal  : " + alasan + " -> sirkit OPEN, tolak semua panggilan");
        }
    }

    public static void main(String[] args) {
        Pemutus sirkit = new Pemutus(3, 2); // ambang 3 kegagalan, masa tenang 2 tick

        System.out.println("== Skenario A: kegagalan beruntun membuka sirkit ==");
        for (int i = 1; i <= 3; i++) {
            System.out.println("  panggilan " + i + " diizinkan? " + sirkit.bolehPanggil());
            sirkit.gagal();
        }

        System.out.println();
        System.out.println("== Skenario B: selama OPEN semua panggilan ditolak ==");
        System.out.println("  panggilan baru diizinkan? " + sirkit.bolehPanggil()
                + " -> langsung fallback");
        sirkit.tick();
        System.out.println("  panggilan baru diizinkan? " + sirkit.bolehPanggil()
                + " (masih OPEN, tick ke-1)");
        sirkit.tick(); // masa tenang 2 tick habis

        System.out.println();
        System.out.println("== Skenario C: satu percobaan di HALF_OPEN ==");
        System.out.println("  panggilan percobaan diizinkan? " + sirkit.bolehPanggil());
        sirkit.sukses();

        System.out.println();
        System.out.println("== Skenario D: percobaan gagal membuka ulang sirkit ==");
        for (int i = 1; i <= 3; i++) {
            sirkit.gagal();
        }
        sirkit.tick();
        sirkit.tick(); // kembali HALF_OPEN
        System.out.println("  panggilan percobaan diizinkan? " + sirkit.bolehPanggil());
        sirkit.gagal(); // percobaan gagal -> OPEN lagi
        System.out.println("  status akhir: " + sirkit.status());
    }
}

Output yang Dihasilkan:

text
== Skenario A: kegagalan beruntun membuka sirkit ==
  panggilan 1 diizinkan? true
  gagal  : kegagalan ke-1, sirkit masih CLOSED
  panggilan 2 diizinkan? true
  gagal  : kegagalan ke-2, sirkit masih CLOSED
  panggilan 3 diizinkan? true
  gagal  : kegagalan ke-3 mencapai ambang 3 -> sirkit OPEN, tolak semua panggilan

== Skenario B: selama OPEN semua panggilan ditolak ==
  panggilan baru diizinkan? false -> langsung fallback
  panggilan baru diizinkan? false (masih OPEN, tick ke-1)
  tick   : masa tenang habis -> OPEN menuju HALF_OPEN

== Skenario C: satu percobaan di HALF_OPEN ==
  panggilan percobaan diizinkan? true
  sukses : percobaan lolos -> HALF_OPEN menuju CLOSED

== Skenario D: percobaan gagal membuka ulang sirkit ==
  gagal  : kegagalan ke-1, sirkit masih CLOSED
  gagal  : kegagalan ke-2, sirkit masih CLOSED
  gagal  : kegagalan ke-3 mencapai ambang 3 -> sirkit OPEN, tolak semua panggilan
  tick   : masa tenang habis -> OPEN menuju HALF_OPEN
  panggilan percobaan diizinkan? true
  gagal  : percobaan gagal -> sirkit OPEN, tolak semua panggilan
  status akhir: OPEN

Mari kita bedah transisi-transisinya:

  1. enum Status dan field kegagalanBeruntun: status CLOSED (jalur normal), OPEN (jalur diputus), HALF_OPEN (menguji pulih). Penghitung kegagalan beruntun direset oleh satu sukses() — sesuai definisi “beruntun”.
  2. gagal() di CLOSED: dua kegagalan pertama hanya menaikkan penghitung; kegagalan ketiga mencapai ambangKegagalan dan memanggil buka(...) yang menyetel status OPEN serta mengisi tickTersisa = masaTenangTick.
  3. bolehPanggil() di OPEN: mengembalikan false — pemanggil menerima fallback seketika dan dependensi yang sakit dibiarkan tanpa lalu lintas. Inilah pembeda circuit breaker dari retry: breaker melindungi dependensi, retry hanya melindungi percobaan.
  4. tick(): satu satuan waktu berlalu. Setelah masa tenang dua tick habis, status turun ke HALF_OPEN — satu percobaan berikutnya diizinkan masuk.
  5. Percobaan tunggal di HALF_OPEN: sukses() menutup sirkit kembali ke CLOSED; gagal() membukanya penuh dengan masa tenang direset — skenario D membuktikan kedua cabang itu pada satu eksekusi.

Ketiganya Bekerja Bersama #

Lapisan-lapisannya menumpuk dari dalam ke luar: timeout membungkus satu panggilan; retry membungkus timeout dengan backoff dan batas percobaan; circuit breaker membungkus retry dengan ambang dan masa tenang:

LapisanPertanyaan yang dijawabParameter khas
Timeout“berapa lama satu percobaan boleh hidup?”1-3 detik per panggilan
Retry“berapa kali dan seberapa jauh jaraknya?”2-4 percobaan, backoff 200 ms naik
Circuit breaker“kapan berhenti mencoba sama sekali?”ambang 5-20 kegagalan, tenang 10-60 detik

Library seperti Resilience4j menyediakan ketiganya siap pakai untuk Spring; memahami state machine di atas membuat parameter pustaka itu bisa diatur dengan sadar, bukan ditebak.

Satu pertanyaan masih terbuka: apa artinya “operasi yang di-retry dengan aman harus idempotent”, dan bagaimana merancang operasi yang aman diulang? Chapter berikutnya menjawabnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

PemutusSirkit.java

Unduh berkas
java
/** Circuit breaker: CLOSED -> OPEN -> HALF_OPEN, digerakkan kejadian eksplisit tanpa waktu nyata. */
public class PemutusSirkit {

    enum Status { CLOSED, OPEN, HALF_OPEN }

    /** State machine circuit breaker dengan ambang kegagalan dan masa tenang berbentuk tick. */
    static class Pemutus {
        private final int ambangKegagalan;
        private final int masaTenangTick;
        private Status status = Status.CLOSED;
        private int kegagalanBeruntun = 0;
        private int tickTersisa = 0;

        Pemutus(int ambangKegagalan, int masaTenangTick) {
            this.ambangKegagalan = ambangKegagalan;
            this.masaTenangTick = masaTenangTick;
        }

        Status status() {
            return status;
        }

        /** Dipanggil sebelum menembak dependensi: apakah diizinkan lewat? */
        boolean bolehPanggil() {
            return status != Status.OPEN;
        }

        void sukses() {
            kegagalanBeruntun = 0;
            if (status == Status.HALF_OPEN) {
                status = Status.CLOSED;
                System.out.println("  sukses : percobaan lolos -> HALF_OPEN menuju CLOSED");
            } else {
                System.out.println("  sukses : kegagalan beruntun direset (tetap CLOSED)");
            }
        }

        void gagal() {
            kegagalanBeruntun++;
            if (status == Status.HALF_OPEN) {
                buka("percobaan gagal");
                return;
            }
            if (kegagalanBeruntun >= ambangKegagalan) {
                buka("kegagalan ke-" + kegagalanBeruntun + " mencapai ambang " + ambangKegagalan);
            } else {
                System.out.println("  gagal  : kegagalan ke-" + kegagalanBeruntun
                        + ", sirkit masih CLOSED");
            }
        }

        /** Satu satuan waktu berlalu; habiskan masa tenang bila sirkit sedang terbuka. */
        void tick() {
            if (status == Status.OPEN) {
                tickTersisa--;
                if (tickTersisa <= 0) {
                    status = Status.HALF_OPEN;
                    System.out.println("  tick   : masa tenang habis -> OPEN menuju HALF_OPEN");
                }
            }
        }

        private void buka(String alasan) {
            status = Status.OPEN;
            tickTersisa = masaTenangTick;
            System.out.println("  gagal  : " + alasan + " -> sirkit OPEN, tolak semua panggilan");
        }
    }

    public static void main(String[] args) {
        Pemutus sirkit = new Pemutus(3, 2); // ambang 3 kegagalan, masa tenang 2 tick

        System.out.println("== Skenario A: kegagalan beruntun membuka sirkit ==");
        for (int i = 1; i <= 3; i++) {
            System.out.println("  panggilan " + i + " diizinkan? " + sirkit.bolehPanggil());
            sirkit.gagal();
        }

        System.out.println();
        System.out.println("== Skenario B: selama OPEN semua panggilan ditolak ==");
        System.out.println("  panggilan baru diizinkan? " + sirkit.bolehPanggil()
                + " -> langsung fallback");
        sirkit.tick();
        System.out.println("  panggilan baru diizinkan? " + sirkit.bolehPanggil()
                + " (masih OPEN, tick ke-1)");
        sirkit.tick(); // masa tenang 2 tick habis

        System.out.println();
        System.out.println("== Skenario C: satu percobaan di HALF_OPEN ==");
        System.out.println("  panggilan percobaan diizinkan? " + sirkit.bolehPanggil());
        sirkit.sukses();

        System.out.println();
        System.out.println("== Skenario D: percobaan gagal membuka ulang sirkit ==");
        for (int i = 1; i <= 3; i++) {
            sirkit.gagal();
        }
        sirkit.tick();
        sirkit.tick(); // kembali HALF_OPEN
        System.out.println("  panggilan percobaan diizinkan? " + sirkit.bolehPanggil());
        sirkit.gagal(); // percobaan gagal -> OPEN lagi
        System.out.println("  status akhir: " + sirkit.status());
    }
}

RetryBerjeda.java

Unduh berkas
java
import java.util.Random;

/** Retry dengan exponential backoff dan jitter, seluruh jeda dihitung sebagai nilai murni. */
public class RetryBerjeda {

    /** Dependensi uji: menjawab kegagalan beberapa kali sebelum akhirnya sehat. */
    static class GudangPalsu {
        private final String kegagalan;
        private int sisaKegagalan;

        GudangPalsu(String kegagalan, int sisaKegagalan) {
            this.kegagalan = kegagalan;
            this.sisaKegagalan = sisaKegagalan;
        }

        String ambil() {
            if (sisaKegagalan > 0) {
                sisaKegagalan--;
                return kegagalan;
            }
            return "200";
        }
    }

    /** Hanya kegagalan transien yang layak diulang. */
    static boolean bolehRetry(String status) {
        return status.startsWith("5") || status.equals("408") || status.equals("429");
    }

    public static void main(String[] args) {
        final long dasarMillis = 200;
        final long batasMillis = 1600;
        final int percobaanMaks = 4;

        // Seed tetap agar contoh dapat direproduksi; produksi memakai tanpa seed.
        Random jitter = new Random(42);

        System.out.println("== Skema jeda: min(dasar * 2^(n-1), batas) + jitter 0..100 ms ==");
        for (int n = 1; n <= 4; n++) {
            long eksponensial = Math.min(dasarMillis << (n - 1), batasMillis);
            long tambahan = jitter.nextInt(101);
            System.out.println("  setelah percobaan " + n + " : tunggu "
                    + (eksponensial + tambahan) + " ms  (" + eksponensial
                    + " ms backoff + " + tambahan + " ms jitter)");
        }

        System.out.println();
        System.out.println("== Gudang pulih setelah 2 kegagalan ==");
        jitter = new Random(42); // reset agar urutan jitter sama dengan skema di atas
        GudangPalsu gudang = new GudangPalsu("503", 2);
        for (int n = 1; n <= percobaanMaks; n++) {
            String jawaban = gudang.ambil();
            if (jawaban.equals("200")) {
                System.out.println("  percobaan " + n + " -> 200 : sukses, retry berhenti");
                break;
            }
            if (!bolehRetry(jawaban)) {
                System.out.println("  percobaan " + n + " -> " + jawaban
                        + " : tidak transien, berhenti tanpa retry");
                break;
            }
            if (n == percobaanMaks) {
                System.out.println("  percobaan " + n + " -> " + jawaban
                        + " : batas " + percobaanMaks + " percobaan tercapai, menyerah");
            } else {
                long eksponensial = Math.min(dasarMillis << (n - 1), batasMillis);
                long tambahan = jitter.nextInt(101);
                System.out.println("  percobaan " + n + " -> " + jawaban + " : tunggu "
                        + (eksponensial + tambahan) + " ms lalu coba lagi");
            }
        }

        System.out.println();
        System.out.println("== Permintaan keliru: retry justru memperburuk ==");
        GudangPalsu gudangSalah = new GudangPalsu("400", 5);
        String jawaban = gudangSalah.ambil();
        if (!bolehRetry(jawaban)) {
            System.out.println("  percobaan 1 -> " + jawaban
                    + " : permintaan kita yang keliru, perbaiki pemanggil");
        }
    }
}