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-After header 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 select dengan ctx.Done(), bukan time.Sleep biasa.

← Sebelumnya: Retry Strategy   Berikutnya: DLQ →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact