Retry Strategy #
Di sistem terdistribusi, tidak semua kegagalan bermakna ada yang salah secara fundamental. Sebagian besar kegagalan bersifat transient — sebuah koneksi database yang sesaat penuh, sebuah API eksternal yang sedang melayani traffic spike, sebuah DNS lookup yang gagal satu kali tapi berhasil di percobaan berikutnya. Jika sistem langsung menyerah pada kegagalan pertama, banyak operasi yang seharusnya bisa berhasil akan menjadi error yang tidak perlu. Tapi jika sistem mencoba lagi tanpa strategi yang tepat — tanpa batasan, tanpa delay, tanpa mempertimbangkan jenis error — retry itu sendiri bisa menjadi sumber masalah: membombardir downstream yang sedang berjuang untuk pulih, atau mengeksekusi operasi non-idempotent dua kali dengan konsekuensi yang tidak diinginkan. Panduan ini membahas retry strategy secara menyeluruh: enam jenis retry dengan karakteristik dan trade-off masing-masing, implementasi konkret di Go dan Dart, klasifikasi error yang boleh dan tidak boleh di-retry, hingga integrasi dengan circuit breaker dan DLQ.
Apa Itu Retry Strategy? #
Retry Strategy adalah pendekatan sistematis untuk mencoba kembali operasi yang gagal, dengan aturan dan batasan yang jelas. Bukan sekadar loop yang mengulangi operasi sampai berhasil, melainkan mekanisme yang sadar tentang kapan retry masuk akal, berapa lama jeda yang diperlukan, dan kapan harus menyerah.
Retry Sederhana (berbahaya):
err := doOperation()
if err != nil {
for { // ← tidak ada batas
err = doOperation()
if err == nil { break }
} // ← tidak ada delay, DDoS ke diri sendiri
}
Retry Strategy (aman):
err := doOperationWithRetry(ctx, config{
MaxAttempts: 5,
BackoffBase: 100 * time.Millisecond,
BackoffMax: 30 * time.Second,
Jitter: true,
RetryableErrors: []ErrorType{Timeout, ServiceUnavailable, TooManyRequests},
})
// error yang tidak retryable langsung fail-fast
// error yang retryable dicoba dengan increasing delay
// setelah max attempts → kirim ke DLQ atau return final error
Retry strategy umumnya diterapkan pada HTTP call antar service, database operation, external API call, message consumer (Kafka, SQS), dan background job.
Mengapa Retry Strategy Penting #
Ada tiga alasan fundamental yang membuat retry bukan opsional di sistem modern.
Kegagalan transient adalah norma. Network timeout, DNS hiccup, service overload sementara, cold start di serverless, dan lock contention yang segera terlepas — semua ini adalah kegagalan yang sembuh dengan sendirinya dalam hitungan milidetik hingga detik. Tanpa retry, setiap kegagalan transient menjadi visible error yang harus ditangani oleh caller atau, lebih buruk, menjadi noise yang membanjiri alert.
Retry adalah fondasi resilience pattern. Circuit breaker, bulkhead, dan timeout semuanya berasumsi bahwa ada mekanisme retry di baliknya. Circuit breaker membuka saat failure rate tinggi dan membiarkan sistem pulih — tapi setelah circuit tertutup kembali, sistem harus bisa retry operasi yang sebelumnya gagal. Tanpa retry yang benar, pattern-pattern ini menjadi kurang efektif.
Reliability tanpa intervensi manual. Sistem yang tidak bisa self-heal dari kegagalan transient membutuhkan on-call engineer untuk merespons setiap incident kecil. Retry yang dirancang dengan baik mengurangi incident rate dan membuat sistem lebih mandiri.
Enam Jenis Retry Strategy #
1. Immediate Retry #
Retry langsung tanpa jeda. Praktis hanya untuk operasi in-memory atau kasus di mana kegagalan benar-benar bersifat satu-satu kali dan sembuh dalam mikrodetik.
// ANTI-PATTERN di hampir semua konteks production
for attempt := 0; attempt < maxAttempts; attempt++ {
err := callExternalService()
if err == nil {
return nil
}
// ← tidak ada delay sama sekali
// → ribuan request gagal serentak akan flood downstream bersamaan
}
Hindari di production untuk operasi network. Jika downstream down 100ms, immediate retry hanya menambah beban tanpa memberi waktu recovery.
2. Fixed Delay Retry #
Retry dengan jeda waktu yang sama setiap kali.
// Fixed delay — sederhana tapi berpotensi thundering herd
func retryFixed(ctx context.Context, maxAttempts int, delay time.Duration,
fn func() error) error {
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
lastErr = fn()
if lastErr == nil {
return nil
}
if attempt < maxAttempts {
select {
case <-time.After(delay): // tunggu sebelum retry
case <-ctx.Done():
return ctx.Err()
}
}
}
return fmt.Errorf("all %d attempts failed: %w", maxAttempts, lastErr)
}
Masalah utama: jika ratusan instance serentak mengalami error dan semua retry setelah delay yang sama persis, mereka semua akan menghantam downstream secara bersamaan (thundering herd).
3. Exponential Backoff #
Delay berlipat ganda setiap kali retry gagal, memberi downstream waktu yang semakin panjang untuk pulih.
// Delay: 100ms → 200ms → 400ms → 800ms → 1600ms
func calculateBackoff(attempt int, base, max time.Duration) time.Duration {
delay := base * time.Duration(1<<uint(attempt-1)) // base * 2^(attempt-1)
if delay > max {
delay = max
}
return delay
}
Jauh lebih baik dari fixed delay, tapi masih bisa thundering herd jika banyak instance retry dengan timing yang sama.
4. Exponential Backoff + Jitter — Best Practice #
Menambahkan randomisasi pada delay untuk menyebar timing retry secara alami dan menghindari thundering herd. Ini adalah strategi yang paling direkomendasikan untuk hampir semua production use case.
// Implementasi lengkap dengan exponential backoff + full jitter
func RetryWithBackoff(ctx context.Context, cfg RetryConfig, fn func() error) error {
var lastErr error
for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
lastErr = fn()
if lastErr == nil {
if attempt > 1 {
log.Infof("retry succeeded on attempt %d", attempt)
}
return nil
}
// Cek apakah error ini worth retrying
if !cfg.IsRetryable(lastErr) {
return fmt.Errorf("non-retryable error: %w", lastErr)
}
if attempt == cfg.MaxAttempts {
break // jangan tunggu setelah attempt terakhir
}
// Hitung delay: base * 2^(attempt-1), capped at max
baseDelay := cfg.BaseDelay * time.Duration(1<<uint(attempt-1))
if baseDelay > cfg.MaxDelay {
baseDelay = cfg.MaxDelay
}
// Full jitter: random antara 0 dan baseDelay
// (lebih efektif daripada ±25% dalam menghindari thundering herd)
jitter := time.Duration(rand.Int63n(int64(baseDelay)))
log.Warnf("attempt %d/%d failed: %v — retrying in %v",
attempt, cfg.MaxAttempts, lastErr, jitter)
select {
case <-time.After(jitter):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("exhausted %d attempts: %w", cfg.MaxAttempts, lastErr)
}
type RetryConfig struct {
MaxAttempts int
BaseDelay time.Duration
MaxDelay time.Duration
IsRetryable func(error) bool
}
5. Retry dengan Deadline / Timeout Budget #
Alih-alih membatasi jumlah attempt, batasi total waktu yang boleh dipakai untuk semua retry. Sangat cocok untuk sistem latency-sensitive di mana user experience bergantung pada respons cepat.
// Retry sampai deadline tercapai — bukan sampai max attempt
func RetryUntilDeadline(ctx context.Context, timeout time.Duration,
fn func() error) error {
deadline := time.Now().Add(timeout)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
attempt := 0
baseDelay := 50 * time.Millisecond
for {
attempt++
err := fn()
if err == nil {
return nil
}
// Hitung remaining time
remaining := time.Until(deadline)
if remaining <= 0 {
return fmt.Errorf("deadline exceeded after %d attempts: %w", attempt, err)
}
// Jangan retry jika delay berikutnya lebih panjang dari remaining time
nextDelay := baseDelay * time.Duration(1<<uint(attempt-1))
if nextDelay > remaining/2 { // sisakan setengah waktu untuk actual call
return fmt.Errorf("insufficient time budget for retry: %w", err)
}
jitter := time.Duration(rand.Int63n(int64(nextDelay)))
select {
case <-time.After(jitter):
case <-ctx.Done():
return ctx.Err()
}
}
}
6. Retry dengan Circuit Breaker #
Circuit breaker dan retry bekerja bersama: retry menangani kegagalan transient, circuit breaker mencegah retry yang sia-sia saat downstream sudah jelas tidak bisa diakses.
Alur retry + circuit breaker:
Attempt 1 gagal → circuit CLOSED → retry dengan backoff
Attempt 2 gagal → circuit CLOSED → retry dengan backoff
Attempt 3 gagal → circuit CLOSED → retry dengan backoff
→ failure rate > threshold → circuit OPEN
Attempt 4 → circuit OPEN → fail fast, tanpa retry
Attempt 5 → circuit OPEN → fail fast, tanpa retry
[setelah cooldown period]
Attempt 6 → circuit HALF-OPEN → coba satu kali
→ sukses → circuit CLOSED kembali
→ gagal → circuit OPEN lagi
Klasifikasi Error: Boleh dan Tidak Boleh di-Retry #
Ini adalah keputusan paling kritis dalam merancang retry strategy. Retry error yang salah bisa menyembunyikan bug atau memperparah situasi.
// Fungsi yang menentukan apakah error layak di-retry
func isRetryableError(err error) bool {
if err == nil {
return false
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
// ✓ BOLEH retry — error transient atau rate limit
case 429: // Too Many Requests — tunggu dan coba lagi
return true
case 500: // Internal Server Error — mungkin transient
return true
case 502: // Bad Gateway — upstream mungkin sedang restart
return true
case 503: // Service Unavailable — downstream sedang overload
return true
case 504: // Gateway Timeout — timeout saja, mungkin berhasil jika retry
return true
// ✗ JANGAN retry — error deterministic, retry tidak akan mengubah hasil
case 400: // Bad Request — request salah, tidak akan berubah kalau diulang
return false
case 401: // Unauthorized — token tidak valid, perlu refresh dulu
return false
case 403: // Forbidden — tidak punya akses, retry tidak akan membantu
return false
case 404: // Not Found — resource tidak ada
return false
case 409: // Conflict — state conflict, butuh logic khusus bukan retry biasa
return false
case 422: // Unprocessable Entity — data tidak valid
return false
}
}
// Network errors yang transient
var netErr *net.OpError
if errors.As(err, &netErr) {
return true // timeout, connection refused — biasanya transient
}
// Context errors — jangan retry
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
return false // default: jangan retry jika tidak tahu
}
Rule of thumb yang sederhana: jika menjalankan operasi yang persis sama dengan input yang persis sama tidak mungkin menghasilkan hasil yang berbeda, jangan retry. Validation error (400, 422) dan authorization error (401, 403) hampir tidak pernah layak di-retry tanpa mengubah sesuatu terlebih dahulu.
Retry dan Idempotency — Hubungan yang Tidak Bisa Dipisahkan #
Retry tanpa mempertimbangkan idempotency adalah bug laten. Setiap operasi yang di-retry harus aman untuk dieksekusi lebih dari sekali.
// ANTI-PATTERN: retry operasi yang tidak idempotent
func processPayment(amount int64) error {
return RetryWithBackoff(ctx, defaultConfig, func() error {
// Jika request ini berhasil tapi response timeout sebelum sampai,
// retry akan memproses payment KEDUA KALI
return paymentGateway.Charge(amount)
})
}
// BENAR: idempotency key memastikan retry aman
func processPayment(idempotencyKey string, amount int64) error {
return RetryWithBackoff(ctx, defaultConfig, func() error {
return paymentGateway.Charge(ChargeRequest{
IdempotencyKey: idempotencyKey, // ← gateway akan deduplicate
Amount: amount,
})
})
}
Aturan praktisnya: sebelum menambahkan retry ke sebuah operasi, pastikan dulu operasi tersebut idempotent — atau buat ia idempotent dengan idempotency key.
Retry di Berbagai Layer Sistem #
Salah satu kesalahan yang sering terjadi adalah menambahkan retry di setiap layer tanpa koordinasi — menghasilkan retry amplification yang memperparah beban pada downstream.
Retry amplification (berbahaya):
Client (3x retry)
│
▼
API Gateway (3x retry)
│
▼
Service A (3x retry)
│
▼
Service B (downstream yang bermasalah)
Total percobaan ke Service B: 3 × 3 × 3 = 27 kali untuk SATU request user!
Best practice: retry di satu layer yang paling tepat
Client → API Gateway → Service A → Service B (bermasalah)
↑
retry di sini saja, layer lain fail-fast
Panduan per layer:
Layer Retry Cocok Untuk Catatan
───────────────── ────────────────────────────────── ──────────────────────────────
HTTP Client Timeout, 5xx dari upstream Harus ada idempotency key
Message Consumer Transient processing error Dikombinasikan dengan DLQ
Background Worker External API call yang flaky Kombinasikan dengan job state
Database Client Deadlock, temporary connection error Sudah built-in di banyak driver
gRPC Client Status codes: UNAVAILABLE, DEADLINE Gunakan retry policy di proto
Di Dart/Flutter, retry sering diterapkan di level Dio interceptor:
// Retry interceptor untuk Dio — berlaku untuk semua HTTP request
class RetryInterceptor extends Interceptor {
final int maxRetries;
final Duration baseDelay;
RetryInterceptor({this.maxRetries = 3, this.baseDelay = const Duration(milliseconds: 200)});
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (_isRetryable(err) && err.requestOptions.extra['retryCount'] == null ||
(err.requestOptions.extra['retryCount'] ?? 0) < maxRetries) {
final retryCount = (err.requestOptions.extra['retryCount'] ?? 0) + 1;
err.requestOptions.extra['retryCount'] = retryCount;
// Exponential backoff dengan jitter
final delay = baseDelay * (1 << (retryCount - 1));
final jitter = Duration(milliseconds: Random().nextInt(delay.inMilliseconds));
await Future.delayed(jitter);
try {
final response = await Dio().fetch(err.requestOptions);
return handler.resolve(response);
} catch (e) {
return handler.next(err);
}
}
return handler.next(err);
}
bool _isRetryable(DioException err) {
if (err.type == DioExceptionType.connectionTimeout) return true;
if (err.type == DioExceptionType.receiveTimeout) return true;
final statusCode = err.response?.statusCode;
return statusCode != null && [429, 500, 502, 503, 504].contains(statusCode);
}
}
Observability untuk Retry #
Retry yang tidak ter-observe adalah retry yang tidak bisa di-tune. Metrics berikut adalah minimum yang harus ada:
// Metrics yang wajib ada untuk setiap retry mechanism
func RetryWithMetrics(ctx context.Context, operationName string,
cfg RetryConfig, fn func() error) error {
startTime := time.Now()
var totalAttempts int
err := RetryWithBackoff(ctx, cfg, func() error {
totalAttempts++
attemptErr := fn()
if attemptErr != nil && totalAttempts > 1 {
// Track setiap retry attempt
metrics.Counter("retry.attempt").
Tag("operation", operationName).
Tag("attempt", strconv.Itoa(totalAttempts)).
Inc()
}
return attemptErr
})
duration := time.Since(startTime)
if err != nil {
metrics.Counter("retry.exhausted").Tag("operation", operationName).Inc()
metrics.Histogram("retry.total_duration_on_failure").
Tag("operation", operationName).
Record(duration)
} else if totalAttempts > 1 {
metrics.Counter("retry.succeeded_after_retry").
Tag("operation", operationName).
Tag("attempts", strconv.Itoa(totalAttempts)).
Inc()
}
return err
}
Anti-Pattern yang Harus Dihindari #
// ✗ Infinite retry — sistem stuck, resource leak
for {
err := callService()
if err == nil { break }
time.Sleep(100 * time.Millisecond)
}
// ✓ Selalu ada max attempts atau deadline
// ✗ Retry semua error tanpa klasifikasi
RetryWithBackoff(ctx, cfg, func() error {
return validateUserInput(req) // 400 errors tidak akan berubah kalau di-retry
})
// ✓ Definisikan IsRetryable yang eksplisit
// ✗ Retry berlapis tanpa koordinasi — retry amplification
// Client retry 3x ke gateway, gateway retry 3x ke service = 9x call
// ✓ Tentukan satu layer yang bertanggung jawab untuk retry
// ✗ Tidak ada delay sama sekali — DDoS ke diri sendiri
for i := 0; i < maxRetries; i++ {
err := callService()
if err == nil { return nil }
// tidak ada sleep
}
// ✓ Minimal fixed delay, idealnya exponential backoff + jitter
// ✗ Retry operasi yang tidak idempotent tanpa idempotency key
RetryWithBackoff(ctx, cfg, func() error {
return chargeCard(userID, amount) // bisa double charge!
})
// ✓ Pastikan idempotency sebelum menambahkan retry
// ✗ Menyembunyikan semua error setelah retry habis
if err := RetryWithBackoff(...); err != nil {
log.Error(err)
return nil // ← caller tidak tahu ada error
}
// ✓ Propagate error, biarkan caller yang memutuskan apa yang harus dilakukan
Ringkasan #
- Retry Strategy adalah pendekatan sistematis — bukan sekadar loop, tapi mekanisme dengan aturan jelas tentang kapan, berapa kali, dengan jeda berapa, dan untuk error apa.
- Enam jenis: immediate (hindari), fixed delay (thundering herd risk), exponential backoff (lebih baik), exponential backoff + jitter (best practice), deadline-based (untuk latency-sensitive), dan kombinasi dengan circuit breaker.
- Exponential backoff + jitter adalah default terbaik — memberi waktu downstream untuk pulih sekaligus menyebar timing retry untuk menghindari thundering herd.
- Klasifikasi error adalah kritis: 4xx (kecuali 429) hampir tidak pernah layak di-retry; 5xx dan timeout biasanya layak di-retry.
- Idempotency adalah prasyarat: sebelum menambahkan retry ke operasi apapun, pastikan operasi tersebut aman dijalankan lebih dari sekali — gunakan idempotency key jika perlu.
- Retry amplification terjadi saat setiap layer melakukan retry independen — tentukan satu layer yang bertanggung jawab, layer lain fail-fast.
- Context cancellation harus dihormati — selalu cek
ctx.Done()di antara retry; jangan retry setelah context cancelled atau deadline terlewati.- Observability wajib: track retry count, retry success rate, dan total duration termasuk semua retry — tanpa ini tidak bisa tahu apakah retry configuration sudah optimal.
- Selalu ada batas: max attempts atau total timeout budget — infinite retry adalah path menuju resource leak dan system stuck.
← Sebelumnya: Aspect Oriented Programming Berikutnya: Backoff Strategy →