Retry dan Exponential Backoff dengan Jitter untuk API dan Consumer

Retry yang baik punya tiga unsur: hanya mengulang kegagalan yang memang mungkin pulih, memberi jeda yang makin panjang di setiap percobaan (exponential backoff), dan mengacak jeda itu (jitter) agar ribuan klien tidak mencoba ulang pada detik yang sama. Tambahkan batas jumlah percobaan dan batas waktu total, lalu serahkan kegagalan yang tersisa ke mekanisme lain seperti dead letter queue atau tugas tertunda.

Tanpa ketiga unsur itu, retry justru bisa memperparah gangguan. Layanan yang sedang kewalahan menerima gelombang permintaan ulang tepat ketika ia butuh napas.

Mengapa jeda tetap tidak cukup

Misalkan API ongkos kirim mati selama 30 detik, dan 500 instance checkout memakai retry dengan jeda tetap satu detik. Setiap detik API itu menerima 500 permintaan ulang sekaligus. Begitu API mulai pulih, gelombang tersebut bisa langsung menjatuhkannya lagi. Masalah ini sering disebut thundering herd.

Exponential backoff mengurangi jumlah percobaan seiring waktu: 1 detik, 2 detik, 4 detik, 8 detik, dan seterusnya. Namun jika semua klien gagal di waktu yang sama, mereka tetap mencoba ulang di waktu yang sama, hanya dengan jeda lebih panjang. Jitter memecah keserempakan itu dengan memilih jeda acak di dalam rentang tertentu.

Rumus yang praktis

Varian yang sering dipakai adalah full jitter:

batas = min(jedaMaksimum, jedaDasar * 2^percobaan)
jeda  = angka acak antara 0 dan batas

Dengan jeda dasar 200 milidetik dan jeda maksimum 10 detik, percobaan pertama menunggu acak 0 sampai 400 ms, percobaan kedua 0 sampai 800 ms, dan seterusnya sampai batas 10 detik. Rata-rata jedanya tetap tumbuh, tetapi waktu percobaan setiap klien tersebar. Varian lain seperti equal jitter (setengah jeda tetap, setengah acak) juga wajar bila Anda ingin jeda minimum yang terjamin.

Implementasi di Java

import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;

public class ApiOngkir {
    private static final int MAKS_PERCOBAAN = 4;
    private static final long JEDA_DASAR_MS = 200;
    private static final long JEDA_MAKS_MS = 10_000;

    private final HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(2))
            .build();

    public String ambilTarif(URI uri) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(uri)
                .timeout(Duration.ofSeconds(3))
                .GET()
                .build();
        for (int percobaan = 1; ; percobaan++) {
            try {
                HttpResponse<String> response =
                        client.send(request, HttpResponse.BodyHandlers.ofString());
                int status = response.statusCode();
                if (status < 400) {
                    return response.body();
                }
                if (!bolehDiulang(status) || percobaan == MAKS_PERCOBAAN) {
                    throw new IOException("API ongkir gagal, status " + status);
                }
                Thread.sleep(jeda(percobaan, retryAfter(response)));
            } catch (HttpTimeoutException e) {
                if (percobaan == MAKS_PERCOBAAN) {
                    throw e;
                }
                Thread.sleep(jeda(percobaan, Optional.empty()));
            }
        }
    }

    private static boolean bolehDiulang(int status) {
        return status == 429 || status == 502 || status == 503 || status == 504;
    }

    private static long jeda(int percobaan, Optional<Long> retryAfterMs) {
        long batas = Math.min(JEDA_MAKS_MS, JEDA_DASAR_MS * (1L << percobaan));
        long acak = ThreadLocalRandom.current().nextLong(batas + 1);
        return Math.max(acak, retryAfterMs.orElse(0L));
    }

    private static Optional<Long> retryAfter(HttpResponse<?> response) {
        return response.headers().firstValue("Retry-After")
                .filter(v -> !v.isEmpty() && v.chars().allMatch(Character::isDigit))
                .map(v -> Long.parseLong(v) * 1000);
    }
}

Hal yang perlu diperhatikan: header Retry-After dari server dihormati bila berupa angka detik (header ini juga bisa berisi tanggal, yang di contoh ini diabaikan), setiap permintaan punya timeout sendiri, dan jumlah percobaan dibatasi. Di produksi, pertimbangkan library seperti Resilience4j yang menyediakan retry, backoff, dan circuit breaker siap pakai, sehingga Anda tidak memelihara kode seperti ini di banyak tempat. Selain itu, Thread.sleep memblokir thread; untuk server dengan banyak permintaan bersamaan, pakai pendekatan asinkron atau virtual thread.

Kapan tidak boleh retry

Bagian ini sering diabaikan, padahal retry yang salah tempat lebih berbahaya daripada tidak ada retry sama sekali.

  • Error dari sisi klien (400, 401, 403, 404, 422). Permintaan Anda yang salah: data tidak valid, token kedaluwarsa, atau sumber daya tidak ada. Mengulang tanpa mengubah apa pun pasti gagal lagi. Untuk 401, perbarui token dulu, baru coba satu kali.
  • Operasi tidak idempoten tanpa kunci idempotensi. POST yang membuat order pengiriman atau menagih pembayaran bisa saja sudah berhasil di server walau klien menerima timeout. Retry buta berarti dua order atau dua tagihan. Kirim kunci idempotensi bila API mendukungnya, atau cek status dulu sebelum mencoba ulang.
  • Saat pengguna sedang menunggu. Di halaman checkout, total waktu retry harus tetap di bawah batas sabar pengguna. Lebih baik satu-dua percobaan cepat lalu tampilkan fallback daripada membuat halaman berputar 30 detik.
  • Di setiap lapisan sekaligus. Jika gateway, layanan A, dan layanan B masing-masing mencoba tiga kali, satu permintaan asli bisa berubah menjadi 27 panggilan ke layanan paling bawah. Tentukan satu lapisan yang bertanggung jawab atas retry.
  • Ketika layanan tujuan jelas sedang mati. Di sinilah peran circuit breaker: setelah sejumlah kegagalan, panggilan langsung ditolak selama beberapa waktu tanpa membebani tujuan, lalu dicoba lagi secara terbatas.

Retry pada consumer pesan

Consumer pesan punya pilihan yang lebih baik daripada Thread.sleep di dalam listener. Tidur di dalam listener menahan thread consumer dan, pada beberapa broker, menahan pesan lain di belakangnya. Pendekatan yang lebih sehat:

  1. Retry singkat di dalam proses untuk gangguan yang sangat sementara, misalnya satu-dua kali dengan jeda ratusan milidetik.
  2. Kembalikan ke broker dengan jeda bila masih gagal: rollback atau tidak mengakui pesan, dan biarkan broker mengatur redelivery delay yang meningkat. ActiveMQ Artemis mendukung redelivery delay dengan pengali; di RabbitMQ biasanya memakai queue tunda dengan TTL.
  3. Kirim ke dead letter queue setelah batas percobaan habis, sehingga pesan bisa diselidiki tanpa menghambat yang lain. Detailnya ada di artikel redelivery dan dead letter queue.

Karena retry berarti pesan atau permintaan yang sama bisa diproses lebih dari sekali, sisi penerima perlu kebal terhadap duplikat. Polanya dibahas di idempotent consumer.

Daftar periksa singkat

  • Setiap panggilan jaringan punya timeout eksplisit.
  • Hanya status dan exception tertentu yang diulang.
  • Jeda tumbuh eksponensial, diacak, dan punya batas atas.
  • Ada batas jumlah percobaan dan batas waktu total.
  • Header Retry-After dihormati.
  • Operasi yang tidak idempoten memakai kunci idempotensi.
  • Jumlah retry tercatat di log dan metrik, sehingga lonjakan retry terlihat sebelum menjadi gangguan besar.
Hendra Kurniawan

Oleh Hendra Kurniawan

Pengembang backend Java yang lama menangani integrasi sistem dan message broker. Menulis catatan praktis tentang JMS, API, dan arsitektur aplikasi.