Backoff Strategy #
Retry strategy menentukan apakah sebuah operasi layak dicoba ulang. Backoff strategy menentukan kapan dan seberapa lama menunggu sebelum mencobanya lagi. Keduanya tidak bisa dipisahkan — retry tanpa backoff hampir selalu memperburuk situasi. Bayangkan satu service yang sedang overload: setiap instance yang mendapat timeout langsung mengirim ulang request, dan seratus instance yang semuanya retry dalam waktu yang hampir bersamaan menghasilkan gelombang traffic yang jauh lebih besar dari yang bisa ditangani service yang sudah kelelahan. Ini adalah thundering herd problem — bukan kegagalan yang tiba-tiba, melainkan kegagalan yang dipercepat oleh retry itu sendiri. Backoff strategy adalah mekanisme yang mencegah hal itu dengan cara yang matematis: memperlambat, menyebar, dan mengacak timing retry sehingga downstream punya ruang untuk pulih. Panduan ini membahas empat jenis backoff dengan formula konkret, tiga varian jitter dengan perbandingan efektivitas, implementasi lengkap di Go dan Dart, hingga integrasi dengan circuit breaker dan DLQ.
Masalah Nyata Tanpa Backoff #
Sebelum masuk ke jenis-jenis backoff, penting memahami secara konkret apa yang terjadi tanpa backoff yang baik.
Skenario tanpa backoff — thundering herd:
t=0:00 Service B mulai overload, response time naik ke 2000ms
t=0:02 100 client request timeout → semua retry langsung
→ Service B menerima 100 request baru dalam 1ms
t=0:04 100 request lagi timeout → semua retry langsung
→ Service B menerima 200 request sekaligus (100 baru + 100 retry)
t=0:06 Service B crash total
t=0:08 Tim on-call dibangunkan jam 3 pagi
Skenario dengan exponential backoff + jitter:
t=0:00 Service B mulai overload
t=0:02 100 client request timeout → retry dijadwalkan dengan jitter
→ retry tersebar antara 100ms–300ms, 200ms–600ms, dst.
t=0:05 Service B menerima request secara bertahap, punya waktu untuk recover
t=0:15 Service B pulih, semua request berhasil di-retry
Thundering herd bukan hanya tentang retry — ia juga bisa terjadi ketika banyak cache entries expired bersamaan, ketika banyak scheduled job dijalankan pada detik yang sama, atau ketika circuit breaker terbuka kembali setelah cooldown dan semua client mencoba serentak. Jitter adalah solusi universal untuk semua skenario ini.
Empat Jenis Backoff #
1. Fixed Backoff #
Delay yang sama setiap kali retry, tidak peduli sudah berapa kali gagal.
// Fixed backoff — sederhana tapi tidak adaptif
func calculateFixedBackoff(delay time.Duration) time.Duration {
return delay
}
// Pola delay: 1s → 1s → 1s → 1s → 1s
// Tidak ada adaptasi terhadap kondisi downstream
Kapan layak dipakai: sistem kecil dengan satu atau dua client, beban rendah, dan kegagalan yang sangat jarang. Tidak cocok untuk sistem dengan banyak concurrent client karena masalah thundering herd tetap ada.
2. Linear Backoff #
Delay bertambah secara linear — setiap retry menambahkan satu unit waktu dari delay sebelumnya.
// Linear backoff
// Rumus: delay = base_delay × attempt_number
func calculateLinearBackoff(attempt int, baseDelay time.Duration) time.Duration {
return baseDelay * time.Duration(attempt)
}
// base = 1s: 1s → 2s → 3s → 4s → 5s
// Lebih baik dari fixed, tapi pertumbuhan lambat di attempt awal
// dan masih berpotensi thundering herd tanpa jitter
Lebih baik dari fixed karena memberi lebih banyak waktu untuk recovery setelah banyak kegagalan, tapi tetap tidak cukup agresif untuk sistem dengan banyak client tanpa jitter.
3. Exponential Backoff #
Delay berlipat ganda setiap kali retry. Ini adalah algoritma yang paling efektif untuk memberi waktu sistem downstream pulih — pertumbuhan eksponensial berarti setelah beberapa kegagalan, delay menjadi cukup panjang untuk recovery yang nyata.
// Exponential backoff
// Rumus: delay = base_delay × 2^(attempt-1)
func calculateExponentialBackoff(attempt int, baseDelay, maxDelay time.Duration) time.Duration {
if attempt <= 0 {
return baseDelay
}
// Cegah overflow untuk attempt yang sangat besar
if attempt > 30 {
return maxDelay
}
delay := baseDelay * time.Duration(1<<uint(attempt-1))
if delay > maxDelay || delay < 0 { // < 0 berarti overflow
return maxDelay
}
return delay
}
// base=100ms, max=30s:
// attempt 1: 100ms
// attempt 2: 200ms
// attempt 3: 400ms
// attempt 4: 800ms
// attempt 5: 1600ms
// attempt 6: 3200ms
// attempt 7: 6400ms
// attempt 8: 12800ms
// attempt 9: 25600ms
// attempt 10: 30000ms (capped)
Masalah yang tersisa: semua client yang mengalami kegagalan pada waktu yang sama akan retry pada delay yang persis sama (100ms, 200ms, 400ms…). Ini tetap bisa menyebabkan thundering herd meskipun delay-nya exponential.
4. Exponential Backoff + Jitter — Best Practice #
Menambahkan randomisasi pada delay untuk menyebar timing retry secara alami. Ada tiga varian jitter yang umum digunakan, masing-masing dengan karakteristik yang berbeda.
Full Jitter — delay sepenuhnya random antara 0 dan nilai exponential:
// Full Jitter — distribusi paling merata
// delay = random(0, base × 2^attempt)
func fullJitter(attempt int, baseDelay, maxDelay time.Duration) time.Duration {
cap := calculateExponentialBackoff(attempt, baseDelay, maxDelay)
return time.Duration(rand.Int63n(int64(cap) + 1))
}
// Distribusi contoh untuk attempt 3 (base=100ms, max=30s → cap=400ms):
// bisa: 12ms, 387ms, 203ms, 51ms, 341ms
// → sangat tersebar, thundering herd tidak mungkin terjadi
Equal Jitter — delay adalah setengah nilai exponential ditambah random setengahnya:
// Equal Jitter — lebih minimal daripada full jitter
// delay = (base × 2^attempt) / 2 + random(0, (base × 2^attempt) / 2)
func equalJitter(attempt int, baseDelay, maxDelay time.Duration) time.Duration {
cap := calculateExponentialBackoff(attempt, baseDelay, maxDelay)
half := cap / 2
return half + time.Duration(rand.Int63n(int64(half)+1))
}
// Distribusi contoh untuk attempt 3 (cap=400ms):
// bisa: 200ms–400ms (selalu minimal 200ms)
// → tersebar tapi dengan floor yang predictable
Decorrelated Jitter — delay bergantung pada delay sebelumnya, menghasilkan distribusi yang lebih natural:
// Decorrelated Jitter — direkomendasikan AWS untuk distributed systems
// delay = random(base_delay, min(max_delay, prev_delay × 3))
func decorrelatedJitter(prevDelay, baseDelay, maxDelay time.Duration) time.Duration {
minDelay := baseDelay
maxJitter := prevDelay * 3
if maxJitter > maxDelay {
maxJitter = maxDelay
}
if maxJitter < minDelay {
maxJitter = minDelay
}
return minDelay + time.Duration(rand.Int63n(int64(maxJitter-minDelay)+1))
}
Perbandingan ketiga varian:
Varian Distribusi Minimum delay Cocok untuk
──────────────── ────────────────── ────────────── ──────────────────────
Full Jitter Sangat tersebar 0ms Distributed system besar
Equal Jitter Tersebar sedang cap/2 Sistem yang butuh predictability
Decorrelated Natural/organic baseDelay AWS-style workload
AWS dan Google merekomendasikan full jitter sebagai default untuk sebagian besar kasus karena menghasilkan distribusi yang paling merata dan paling efektif mencegah thundering herd.
Implementasi Lengkap di Go #
type BackoffConfig struct {
InitialDelay time.Duration
MaxDelay time.Duration
Multiplier float64 // biasanya 2.0 untuk exponential
Jitter JitterType
MaxAttempts int
}
type JitterType int
const (
NoJitter JitterType = iota
FullJitter
EqualJitter
DecorrelatedJitter
)
type Backoff struct {
config BackoffConfig
attempt int
prev time.Duration
rng *rand.Rand
}
func NewBackoff(cfg BackoffConfig) *Backoff {
return &Backoff{
config: cfg,
prev: cfg.InitialDelay,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// NextDelay menghitung delay untuk attempt berikutnya
func (b *Backoff) NextDelay() (time.Duration, bool) {
b.attempt++
if b.config.MaxAttempts > 0 && b.attempt > b.config.MaxAttempts {
return 0, false // tidak ada lagi attempt
}
// Hitung base exponential delay
base := float64(b.config.InitialDelay) *
math.Pow(b.config.Multiplier, float64(b.attempt-1))
if base > float64(b.config.MaxDelay) {
base = float64(b.config.MaxDelay)
}
var delay time.Duration
switch b.config.Jitter {
case FullJitter:
delay = time.Duration(b.rng.Int63n(int64(base) + 1))
case EqualJitter:
half := base / 2
delay = time.Duration(half) + time.Duration(b.rng.Int63n(int64(half)+1))
case DecorrelatedJitter:
maxJitter := float64(b.prev) * 3
if maxJitter > float64(b.config.MaxDelay) {
maxJitter = float64(b.config.MaxDelay)
}
min := float64(b.config.InitialDelay)
delay = time.Duration(min) +
time.Duration(b.rng.Int63n(int64(maxJitter-min)+1))
default: // NoJitter
delay = time.Duration(base)
}
b.prev = delay
return delay, true
}
// Reset untuk digunakan ulang
func (b *Backoff) Reset() {
b.attempt = 0
b.prev = b.config.InitialDelay
}
// Penggunaan dalam retry loop
func callWithBackoff(ctx context.Context, fn func() error) error {
bo := NewBackoff(BackoffConfig{
InitialDelay: 100 * time.Millisecond,
MaxDelay: 30 * time.Second,
Multiplier: 2.0,
Jitter: FullJitter,
MaxAttempts: 8,
})
var lastErr error
for {
lastErr = fn()
if lastErr == nil {
return nil
}
delay, hasMore := bo.NextDelay()
if !hasMore {
break
}
log.Warnf("attempt %d failed: %v — backing off %v", bo.attempt, lastErr, delay)
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("all attempts exhausted: %w", lastErr)
}
Implementasi di Dart/Flutter #
// Backoff calculator di Dart — digunakan di Dio interceptor atau repository
class ExponentialBackoff {
final Duration initialDelay;
final Duration maxDelay;
final double multiplier;
final bool useJitter;
final int maxAttempts;
const ExponentialBackoff({
this.initialDelay = const Duration(milliseconds: 100),
this.maxDelay = const Duration(seconds: 30),
this.multiplier = 2.0,
this.useJitter = true,
this.maxAttempts = 5,
});
Duration calculate(int attempt) {
// Base exponential delay
final base = initialDelay.inMilliseconds *
math.pow(multiplier, attempt - 1).toDouble();
final capped = math.min(base, maxDelay.inMilliseconds.toDouble());
if (!useJitter) {
return Duration(milliseconds: capped.round());
}
// Full jitter: random antara 0 dan capped
final jittered = (Random().nextDouble() * capped).round();
return Duration(milliseconds: jittered);
}
bool hasMoreAttempts(int attempt) => attempt <= maxAttempts;
}
// Penggunaan di repository layer
class OrderRepository {
final ApiClient _client;
final ExponentialBackoff _backoff;
OrderRepository(this._client,
{ExponentialBackoff? backoff})
: _backoff = backoff ?? const ExponentialBackoff();
Future<Order> fetchOrder(String orderId) async {
Exception? lastError;
for (int attempt = 1; attempt <= _backoff.maxAttempts; attempt++) {
try {
return await _client.get('/orders/$orderId');
} on DioException catch (e) {
if (!_isRetryable(e)) rethrow; // non-retryable error — langsung throw
lastError = e;
if (!_backoff.hasMoreAttempts(attempt + 1)) break;
final delay = _backoff.calculate(attempt);
debugPrint('[Backoff] attempt $attempt failed, retrying in ${delay.inMilliseconds}ms');
await Future.delayed(delay);
}
}
throw lastError ?? Exception('All attempts exhausted for order $orderId');
}
bool _isRetryable(DioException e) {
final status = e.response?.statusCode;
if (e.type == DioExceptionType.connectionTimeout) return true;
if (e.type == DioExceptionType.receiveTimeout) return true;
return status != null && [429, 500, 502, 503, 504].contains(status);
}
}
Backoff dan HTTP 429 (Rate Limit) #
HTTP 429 (Too Many Requests) adalah kasus khusus — server biasanya menyertakan header Retry-After yang memberi tahu kapan boleh retry. Backoff yang baik harus menghormati header ini.
// Backoff yang menghormati Retry-After header
func getRetryDelay(resp *http.Response, attempt int, cfg BackoffConfig) time.Duration {
if resp != nil && resp.StatusCode == 429 {
// Cek Retry-After header — ada dua format
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
// Format 1: detik (angka integer)
if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second
}
// Format 2: HTTP date
if t, err := http.ParseTime(retryAfter); err == nil {
remaining := time.Until(t)
if remaining > 0 {
return remaining
}
}
}
// Header tidak ada — gunakan backoff normal tapi lebih konservatif
return calculateExponentialBackoff(attempt, cfg.InitialDelay*5, cfg.MaxDelay)
}
// Untuk error lain — gunakan backoff normal dengan jitter
return fullJitter(attempt, cfg.InitialDelay, cfg.MaxDelay)
}
Integrasi dengan Circuit Breaker #
Backoff dan circuit breaker adalah dua mekanisme resilience yang saling melengkapi — bukan alternatif satu sama lain.
Backoff saja (tanpa circuit breaker):
Attempt 1 gagal → tunggu 100ms
Attempt 2 gagal → tunggu 200ms
Attempt 3 gagal → tunggu 400ms
Attempt 4 gagal → tunggu 800ms
... semua attempt tetap dilakukan meski jelas downstream tidak bisa diakses
Backoff + Circuit Breaker (ideal):
Attempt 1-3 gagal → retry dengan backoff normal
Failure rate tinggi → circuit OPEN
Attempt 4-10 → fail fast tanpa mencoba → sistem bisa merespons dengan fallback
Setelah cooldown → circuit HALF-OPEN → coba sekali
Berhasil → circuit CLOSED → kembali normal dengan backoff
// Kombinasi backoff + circuit breaker
type ResilientClient struct {
cb *gobreaker.CircuitBreaker
backoff BackoffConfig
}
func (c *ResilientClient) Call(ctx context.Context, fn func() error) error {
bo := NewBackoff(c.backoff)
var lastErr error
for {
// Circuit breaker membungkus setiap attempt
_, cbErr := c.cb.Execute(func() (interface{}, error) {
return nil, fn()
})
if cbErr == nil {
return nil
}
// Jika circuit breaker open, fail fast — tidak perlu tunggu backoff
if errors.Is(cbErr, gobreaker.ErrOpenState) {
return fmt.Errorf("circuit open, fast fail: %w", cbErr)
}
lastErr = cbErr
delay, hasMore := bo.NextDelay()
if !hasMore {
break
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("exhausted after %d attempts: %w", bo.attempt, lastErr)
}
Checklist Backoff Production-Ready #
KONFIGURASI:
□ Initial delay sesuai SLA — jangan terlalu pendek (< 50ms untuk API eksternal)
□ Max delay ditetapkan — jangan biarkan backoff tumbuh tak terbatas
□ Multiplier menggunakan 2.0 (standar exponential)
□ Max attempts ditetapkan (rekomendasi: 3-8 tergantung konteks)
□ Jitter diaktifkan — full jitter sebagai default
KLASIFIKASI ERROR:
□ Hanya error transient yang di-retry (5xx, timeout, 429)
□ Error deterministic di-fail-fast (4xx kecuali 429)
□ HTTP 429 menghormati Retry-After header jika ada
INTEGRASI:
□ Context cancellation dihormati antar retry
□ Circuit breaker terintegrasi untuk mencegah retry yang sia-sia
□ DLQ tersedia sebagai safety net setelah semua retry habis
OBSERVABILITY:
□ Setiap attempt di-log dengan attempt number dan delay yang digunakan
□ Metrics: attempt count, backoff exhausted, success after retry
□ Alert jika backoff exhausted rate melebihi threshold
Anti-Pattern yang Harus Dihindari #
// ✗ Backoff tanpa jitter di sistem dengan banyak instance
delay := baseDelay * time.Duration(1<<attempt) // semua instance delay sama
// ✓ Selalu tambahkan full jitter
// ✗ Tidak ada max delay — delay bisa sangat panjang
delay := 100 * time.Millisecond * time.Duration(1<<attempt)
// attempt 20: ~100 detik, attempt 30: ~100 juta detik
// ✓ Selalu set maxDelay
// ✗ Mengabaikan context cancellation — thread leak saat user cancel request
time.Sleep(delay) // tidak bisa di-cancel
// ✓ Gunakan select dengan ctx.Done()
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
// ✗ Backoff untuk semua error termasuk yang non-retryable
// Validation error (400) tidak akan berubah betapapun lamanya menunggu
// ✓ Selalu klasifikasikan error sebelum memutuskan backoff
// ✗ Reset backoff counter setelah satu sukses dalam batch
// Jika ada 1 sukses di antara banyak gagal, attempt counter di-reset
// → backoff kembali ke initial delay, thundering herd bisa kembali
// ✓ Backoff state per operasi, bukan per batch
Ringkasan #
- Backoff strategy menentukan kapan dan seberapa lama menunggu sebelum retry — komplemen dari retry strategy yang menentukan apakah layak retry.
- Thundering herd problem terjadi ketika banyak client retry bersamaan, memperparah kondisi downstream yang sudah overload — backoff + jitter adalah solusi utamanya.
- Empat jenis backoff: fixed (tidak adaptif), linear (pertumbuhan lambat), exponential (efektif), exponential + jitter (best practice untuk production).
- Tiga varian jitter: full jitter (distribusi paling merata, direkomendasikan), equal jitter (ada floor predictable), decorrelated jitter (natural/organic, AWS-style).
- Full jitter adalah default terbaik karena menghasilkan distribusi paling merata dan paling efektif mencegah thundering herd di sistem dengan banyak concurrent client.
- HTTP 429 harus dihormati — gunakan
Retry-Afterheader jika ada, jangan gunakan backoff normal yang mungkin terlalu cepat.- Backoff + circuit breaker adalah kombinasi ideal: backoff untuk kegagalan transient, circuit breaker untuk fail-fast saat downstream jelas tidak tersedia.
- Max delay wajib: tanpa batas atas, backoff bisa tumbuh ke ratusan detik yang menyebabkan resource leak dan user experience yang sangat buruk.
- Context cancellation harus dihormati di antara setiap attempt — gunakan
selectdenganctx.Done(), bukantime.Sleepbiasa.