Tampilan
Kenapa penting
Di bab sebelumnya kamu membuat client yang menerjemahkan error dan punya timeout. Tapi di production, gangguan itu rutin: deploy layanan lawan, jaringan tersendat beberapa detik, atau database mereka penuh. Tanpa strategi resilience, satu dependency yang sakit menjalar menjadi seluruh sistem down — pengguna tidak bisa checkout hanya karena layanan rekomendasi produk lambat. Engineer backend diharapkan bisa memilih pola yang tepat (timeout, retry, circuit breaker, bulkhead, rate limiter, fallback), tahu kapan pola itu berbahaya, dan bisa membuktikan lewat metrik bahwa pola itu bekerja.
Konsep inti
Timeout: lapisan pertama
Semua pola lain bergantung pada timeout. Retry tanpa timeout berarti menunggu tanpa batas lalu menunggu lagi; circuit breaker tidak bisa menghitung kegagalan dari panggilan yang tidak pernah selesai. Aturannya sederhana: setiap panggilan keluar punya timeout, dan nilainya disesuaikan dengan SLA lawan serta batas waktu request-mu sendiri.
Perhitungkan juga anggaran total. Jika endpoint checkout harus menjawab dalam 3 detik, read timeout 2 detik dengan 3 kali retry sudah melewati anggaran itu. Timeout per panggilan × jumlah percobaan + jeda harus tetap masuk akal.
Retry dengan backoff dan jitter
Retry menolong untuk kegagalan sementara: timeout sesaat, koneksi terputus, 503, atau 429. Retry tidak menolong untuk 400 (request salah) atau 404 — hasilnya akan sama terus.
Tiga aturan retry yang sehat:
- Hanya untuk operasi idempoten.
GETstok aman diulang.POST /paymentstidak — timeout bukan berarti gagal; bisa jadi server sudah menagih. Ulangi hanya bila API menerima idempotency key (dibahas lebih dalam di bab idempotensi & outbox). - Exponential backoff. Jeda bertambah: 100 ms, 200 ms, 400 ms, ... memberi layanan lawan waktu bernapas.
- Jitter. Jeda diacak agar ribuan client tidak mencoba ulang pada milidetik yang sama.
Program berikut menghitung jeda dengan full jitter — jeda acak antara 0 dan nilai backoff eksponensial:
java
import java.util.Random;
public class BackoffDemo {
public static void main(String[] args) {
long baseMs = 100;
long maxMs = 2_000;
Random random = new Random(42); // seed tetap agar output bisa diulang
for (int attempt = 1; attempt <= 5; attempt++) {
long exp = Math.min(maxMs, baseMs * (1L << (attempt - 1))); // 100, 200, 400, ...
long withJitter = random.nextLong(exp + 1); // "full jitter": 0..exp
System.out.printf("retry ke-%d: backoff %4d ms -> tunggu %4d ms%n", attempt, exp, withJitter);
}
}
}text
retry ke-1: backoff 100 ms -> tunggu 2 ms
retry ke-2: backoff 200 ms -> tunggu 106 ms
retry ke-3: backoff 400 ms -> tunggu 260 ms
retry ke-4: backoff 800 ms -> tunggu 517 ms
retry ke-5: backoff 1600 ms -> tunggu 1006 msBatasi juga jumlah retry (biasanya 2–3). Retry berlapis — client retry 3 kali, gateway retry 3 kali, layanan hilir retry 3 kali — bisa melipatgandakan beban menjadi 27 kali saat sistem sedang sakit.
Circuit breaker: berhenti memukul layanan yang sakit
Circuit breaker mengamati hasil panggilan dan punya tiga state:
| State | Perilaku | Pindah ke |
|---|---|---|
CLOSED | Panggilan diteruskan; kegagalan dihitung | OPEN jika tingkat kegagalan melewati ambang |
OPEN | Panggilan ditolak cepat tanpa menyentuh layanan | HALF_OPEN setelah waktu tunggu |
HALF_OPEN | Beberapa panggilan uji diizinkan | CLOSED jika berhasil, OPEN lagi jika gagal |
Breaker sederhana berikut memakai ambang 3 kegagalan berturut-turut dan waktu buka 1 detik. Ini versi mainan untuk memahami mekanismenya — di production pakai library yang sudah teruji:
java
import java.util.function.Supplier;
public class BreakerDemo {
enum State { CLOSED, OPEN, HALF_OPEN }
static class SimpleCircuitBreaker {
private final int failureThreshold;
private final long openMillis;
private State state = State.CLOSED;
private int consecutiveFailures = 0;
private long openedAt = 0;
SimpleCircuitBreaker(int failureThreshold, long openMillis) {
this.failureThreshold = failureThreshold;
this.openMillis = openMillis;
}
<T> T call(Supplier<T> action, Supplier<T> fallback, long now) {
if (state == State.OPEN) {
if (now - openedAt < openMillis) {
return fallback.get(); // tolak cepat, tanpa memanggil layanan
}
state = State.HALF_OPEN; // waktunya mencoba satu panggilan uji
}
try {
T result = action.get();
state = State.CLOSED;
consecutiveFailures = 0;
return result;
} catch (RuntimeException e) {
consecutiveFailures++;
if (state == State.HALF_OPEN || consecutiveFailures >= failureThreshold) {
state = State.OPEN;
openedAt = now;
}
return fallback.get();
}
}
State state() { return state; }
}
public static void main(String[] args) {
SimpleCircuitBreaker breaker = new SimpleCircuitBreaker(3, 1_000);
boolean[] serviceUp = {false};
Supplier<String> callShipping = () -> {
if (!serviceUp[0]) throw new IllegalStateException("503");
return "ongkir 18000";
};
Supplier<String> fallback = () -> "ongkir default 25000";
long[] times = {0, 100, 200, 300, 400, 1_500, 1_600};
for (int i = 0; i < times.length; i++) {
if (times[i] == 1_500) serviceUp[0] = true; // layanan pulih
String result = breaker.call(callShipping, fallback, times[i]);
System.out.printf("t=%4d ms -> %-20s state=%s%n", times[i], result, breaker.state());
}
}
}text
t= 0 ms -> ongkir default 25000 state=CLOSED
t= 100 ms -> ongkir default 25000 state=CLOSED
t= 200 ms -> ongkir default 25000 state=OPEN
t= 300 ms -> ongkir default 25000 state=OPEN
t= 400 ms -> ongkir default 25000 state=OPEN
t=1500 ms -> ongkir 18000 state=CLOSED
t=1600 ms -> ongkir 18000 state=CLOSEDPada t=300 dan t=400, layanan ongkir tidak dipanggil sama sekali — itulah keuntungan breaker: gagal dalam mikrodetik alih-alih menunggu timeout, sekaligus memberi layanan lawan ruang untuk pulih. Pada t=1500, breaker masuk HALF_OPEN, panggilan uji berhasil, dan state kembali CLOSED. Implementasi asli (Resilience4j) memakai sliding window dan persentase kegagalan, bukan sekadar hitungan berturut-turut, serta aman dipakai banyak thread.
Fallback: jawaban cadangan yang jujur
Fallback adalah apa yang dikembalikan saat panggilan gagal atau ditolak breaker. Contoh yang baik: ongkir default yang aman secara bisnis, rekomendasi produk dari cache, atau daftar kosong untuk fitur non-kritis. Contoh yang buruk: menganggap pembayaran berhasil, atau menampilkan stok "tersedia" padahal tidak diketahui. Bila tidak ada jawaban cadangan yang benar, fallback terbaik adalah error yang jelas (misalnya 503) — bukan data palsu.
Bulkhead dan rate limiter
Istilah bulkhead berasal dari sekat kapal: satu kompartemen bocor tidak menenggelamkan kapal. Di aplikasi, bulkhead membatasi jumlah panggilan bersamaan ke satu dependency, sehingga layanan rekomendasi yang lambat tidak menghabiskan semua thread yang juga dibutuhkan checkout.
Rate limiter membatasi jumlah panggilan per periode — misalnya maksimal 50 panggilan per detik ke API ongkir pihak ketiga yang kuotanya terbatas. Bulkhead menjawab "berapa banyak sekaligus", rate limiter menjawab "berapa banyak per detik".
Pilihan di Spring: bawaan Spring Framework 7 atau Resilience4j
Sejak Spring Framework 7, inti Spring punya fitur resilience dasar di paket org.springframework.resilience, aktif setelah kamu menambahkan @EnableResilientMethods pada kelas konfigurasi:
java
// Potongan — Spring Framework 7
@Configuration
@EnableResilientMethods
class ResilienceConfig {
}
@Component
class StockClient {
@Retryable(includes = InventoryUnavailableException.class,
maxRetries = 3, delay = 100, multiplier = 2, jitter = 50, maxDelay = 1000)
StockResponse getStock(String sku) { /* panggil RestClient */ }
@ConcurrencyLimit(10)
List<Product> recommendations(String customerId) { /* panggil layanan rekomendasi */ }
}@Retryable: default-nya retry untuk semua exception,maxRetries = 3, jeda 1 detik. Total percobaan = 1 +maxRetries. Bila semua gagal, exception asli terakhir dilempar ke pemanggil.@ConcurrencyLimit(10): bulkhead sederhana — paling banyak 10 pemanggilan bersamaan.- Untuk retry terprogram tersedia
RetryTemplatedenganRetryPolicy.
Spring Framework 7 tidak menyediakan circuit breaker maupun rate limiter. Untuk itu pakai Resilience4j 2.4 (artefak io.github.resilience4j:resilience4j-spring-boot4 untuk Spring Boot 4, ditambah dukungan AOP dan Actuator). Konfigurasinya per instance di application.yaml:
yaml
resilience4j:
circuitbreaker:
instances:
shipping:
sliding-window-size: 20
minimum-number-of-calls: 10
failure-rate-threshold: 50 # persen
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
retry:
instances:
shipping:
max-attempts: 3 # SUDAH termasuk panggilan pertama
wait-duration: 200ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
enable-randomized-wait: true
retry-exceptions:
- org.springframework.web.client.ResourceAccessException # timeout/koneksi
- org.springframework.web.client.HttpServerErrorException # 5xx
bulkhead:
instances:
recommendation:
max-concurrent-calls: 10
ratelimiter:
instances:
shipping:
limit-for-period: 50
limit-refresh-period: 1s
timeout-duration: 0Lalu beri anotasi pada method (contoh lengkap ada di bagian berikutnya). Bila beberapa anotasi Resilience4j dipasang pada method yang sama, urutan default-nya: Retry paling luar, lalu CircuitBreaker, RateLimiter, TimeLimiter, dan Bulkhead paling dalam. Artinya setiap percobaan retry juga dicatat oleh circuit breaker.
Kapan cukup bawaan Spring?
Butuh retry sederhana atau pembatasan konkurensi saja → @Retryable/@ConcurrencyLimit tanpa dependency tambahan. Butuh circuit breaker, rate limiter, atau metrik resilience yang kaya → Resilience4j.
Metrik: buktikan resilience bekerja
Pola resilience yang tidak dipantau berbahaya: breaker bisa terbuka berjam-jam tanpa ada yang tahu, atau retry diam-diam melipatgandakan trafik. Resilience4j menerbitkan metrik Micrometer seperti resilience4j.circuitbreaker.state, resilience4j.circuitbreaker.calls (berlabel kind: successful, failed, not_permitted), dan resilience4j.retry.calls; Actuator menyediakan endpoint seperti /actuator/circuitbreakers. Di Spring Framework 7, setiap kegagalan pada @Retryable menerbitkan MethodRetryEvent yang bisa kamu tangkap untuk logging atau metrik sendiri. Alert minimal yang layak dipasang: breaker berstatus OPEN lebih dari beberapa menit, dan lonjakan panggilan not_permitted.
Contoh di dunia backend
Checkout menghitung ongkos kirim dari API kurir pihak ketiga. Ongkir penting tapi tidak boleh membuat checkout gagal total, jadi dipakai circuit breaker + retry dengan fallback tarif datar:
java
// Potongan — Spring Boot 4 + Resilience4j 2.4
@Component
public class ShippingClient {
private static final Logger log = LoggerFactory.getLogger(ShippingClient.class);
private final ShippingApi shippingApi; // HTTP interface client dari bab sebelumnya
public ShippingClient(ShippingApi shippingApi) {
this.shippingApi = shippingApi;
}
// GET tarif bersifat idempoten → aman di-retry
// Fallback dipasang di @Retry (lapisan terluar). Jika dipasang di @CircuitBreaker (lapisan dalam),
// fallback "menelan" exception sehingga retry tidak pernah terpicu.
@Retry(name = "shipping", fallbackMethod = "flatRate")
@CircuitBreaker(name = "shipping")
public ShippingQuote quote(String originCity, String destinationCity, int weightGram) {
return shippingApi.getQuote(originCity, destinationCity, weightGram);
}
// Signature sama + parameter exception terakhir
private ShippingQuote flatRate(String originCity, String destinationCity, int weightGram, Throwable cause) {
log.warn("Fallback ongkir datar untuk {} -> {}: {}", originCity, destinationCity, cause.toString());
return new ShippingQuote(new BigDecimal("25000"), "FLAT", true);
}
}
public record ShippingQuote(BigDecimal amount, String service, boolean estimated) {}Hal penting di sini: timeout sudah disetel di level client (spring.http.serviceclient.shipping.read-timeout), retry hanya membungkus operasi baca, CallNotPermittedException dari breaker yang terbuka tidak ada di retry-exceptions sehingga langsung jatuh ke fallback tanpa retry sia-sia, fallback ditandai estimated = true agar UI bisa menampilkan "perkiraan ongkir", dan alasan fallback dicatat di log. Sebaliknya, panggilan POST untuk membuat resi pengiriman tidak diberi @Retry kecuali API kurir menerima idempotency key.
Pertanyaan interview
1. Kenapa retry tanpa jitter bisa berbahaya?
Jika banyak client gagal pada saat yang sama (misalnya karena layanan lawan restart), backoff tanpa jitter membuat mereka semua mencoba ulang pada milidetik yang sama. Gelombang retry serentak ini (thundering herd) bisa menumbangkan layanan yang baru pulih. Jitter menyebarkan retry secara acak sehingga beban lebih rata.
2. Jelaskan state circuit breaker dan kenapa ada HALF_OPEN.
CLOSED meneruskan panggilan sambil menghitung kegagalan; jika melewati ambang, breaker menjadi OPEN dan menolak panggilan secara cepat. Setelah waktu tunggu, HALF_OPEN mengizinkan sejumlah kecil panggilan uji. Tanpa HALF_OPEN, breaker harus memilih antara membanjiri layanan yang belum pulih (langsung CLOSED) atau tidak pernah tahu kapan layanan sudah sehat.
3. Apa beda bulkhead dan rate limiter?
Bulkhead membatasi jumlah panggilan bersamaan untuk mengisolasi sumber daya (thread, koneksi) agar satu dependency lambat tidak menghabiskan semuanya. Rate limiter membatasi jumlah panggilan per satuan waktu, biasanya untuk menghormati kuota API atau melindungi layanan dari lonjakan.
Jebakan umum
- Me-retry operasi yang tidak idempoten (
POSTpembayaran, kirim email) sehingga efeknya terjadi dua kali. - Me-retry error 4xx seperti 400 atau 404 — hasilnya tidak akan berubah, hanya menambah beban.
- Retry berlapis di beberapa tingkat (client, gateway, layanan hilir) yang melipatgandakan trafik saat sistem sakit.
- Fallback yang berbohong, misalnya mengembalikan "stok tersedia" atau "pembayaran sukses" saat layanan tidak bisa dihubungi.
- Memanggil method beranotasi
@Retryable/@CircuitBreakerdari method lain di kelas yang sama — proxy Spring dilewati sehingga anotasi tidak berlaku. - Tertukar semantik:
maxRetriesdi Spring tidak termasuk panggilan pertama,max-attemptsdi Resilience4j sudah termasuk.
Ringkasan
- Timeout adalah fondasi: setiap panggilan keluar wajib punya batas waktu, dan total waktu retry harus masuk anggaran request.
- Retry hanya untuk kegagalan sementara pada operasi idempoten, dengan exponential backoff, jitter, dan jumlah percobaan terbatas.
- Circuit breaker (
CLOSED→OPEN→HALF_OPEN) menghentikan panggilan ke layanan yang sakit dan memberi waktu pulih; fallback harus jujur. - Bulkhead membatasi panggilan bersamaan, rate limiter membatasi panggilan per periode.
- Spring Framework 7 punya
@Retryabledan@ConcurrencyLimit(aktifkan dengan@EnableResilientMethods); circuit breaker dan rate limiter memakai Resilience4j 2.4 — pantau metriknya.