Build CoreOrdered learning track

Retries, Backoff, Jitter, and Retry Storms

Learn Java Microservices Design and Architect - Part 041

Retry, backoff, jitter, retry budget, dan retry storm sebagai control loop reliability dalam Java microservices production-grade.

12 min read2289 words
PrevNext
Lesson 41100 lesson track19–54 Build Core
#java#microservices#resilience#retry+4 more

Part 041 — Retries, Backoff, Jitter, and Retry Storms

Retry terlihat sederhana: request gagal, coba lagi.

Di sistem single-process, intuisi itu sering cukup. Di microservices, retry adalah control loop yang bisa menambah traffic ketika sistem sedang lemah. Retry yang salah bukan hanya gagal menyembuhkan error; ia bisa mempercepat cascading failure.

Part ini tidak membahas retry sebagai annotation. Kita akan membahas retry sebagai load amplifier, semantic decision, dan runtime contract.

Target setelah part ini:

  1. bisa membedakan error yang layak di-retry dan yang tidak;
  2. bisa mendesain retry policy berbasis idempotency, timeout, dan business semantics;
  3. bisa menjelaskan mengapa backoff tanpa jitter masih bisa berbahaya;
  4. bisa mencegah retry storm pada synchronous dan asynchronous flow;
  5. bisa menulis retry logic Java yang production-grade, observable, bounded, dan aman.

1. Core Mental Model

Retry bukan fitur kenyamanan. Retry adalah mekanisme optimisme terkontrol.

Ia mengasumsikan:

kegagalan pertama mungkin transient, dan mencoba lagi dalam batas tertentu lebih murah daripada mengembalikan kegagalan ke caller.

Masalahnya: asumsi itu tidak selalu benar.

Jika downstream sedang overload, retry menambah load. Jika request tidak idempotent, retry bisa menggandakan side effect. Jika timeout terlalu panjang, retry tidak pernah sempat terjadi sebelum user sudah pergi. Jika semua client retry pada waktu yang sama, recovery downstream bisa tertunda.

Mental model yang benar:

Retry harus punya empat pagar:

PagarPertanyaan
Semantic safetyApakah mengulang request aman secara bisnis?
Attempt boundBerapa maksimum percobaan?
Time budgetApakah masih ada waktu dalam deadline end-to-end?
Load disciplineApakah retry tidak memperparah overload downstream?

Tanpa empat pagar ini, retry bukan resilience. Ia hanya denial-of-service yang dilakukan oleh sistem sendiri.


2. Retry is a Load Multiplier

Misalkan satu request dari user memanggil tiga service downstream.

Setiap call diberi retry 3 kali.

Secara lokal, setiap developer merasa aman:

maxAttempts = 3

Tapi secara sistem, call graph bisa berubah menjadi multiplier.

Jika D mulai lambat, B dan C retry. A juga bisa retry ke B dan C. Gateway mungkin retry ke A. Client mobile mungkin retry dari luar.

Retry local yang terlihat kecil dapat menjadi ledakan global.

Rumus sederhananya:

worst_case_attempts = product(maxAttempts_per_layer)

Jika ada 4 layer dan masing-masing maxAttempts=3:

3 x 3 x 3 x 3 = 81 attempts

Satu request user dapat berubah menjadi 81 upaya internal.

Inilah mengapa retry policy tidak boleh menjadi default tersembunyi di setiap library tanpa koordinasi arsitektur.


3. Retry Decision Matrix

Tidak semua error sama.

Retry harus diputuskan berdasarkan jenis kegagalan, kemungkinan transient, idempotency, dan posisi dalam workflow.

FailureRetry?Reasoning
TCP connection resetBiasanya yaBisa transient network/server restart
DNS temporary failureYa, dengan batasBisa transient, tapi jangan loop lama
HTTP 408Ya, jika idempotentRequest timeout bisa transient
HTTP 429Ya, hanya dengan backoff dan Retry-AfterCaller sedang dibatasi
HTTP 500KadangBisa transient, bisa bug permanen
HTTP 502/503/504Biasanya yaGateway/downstream temporary unavailable
HTTP 400TidakRequest salah; retry tidak memperbaiki payload
HTTP 401/403TidakAuth/permission problem
HTTP 404Biasanya tidakKecuali read-after-create dengan known consistency window
Validation errorTidakBusiness input invalid
Optimistic lock conflictYa, dengan re-read atau user conflict flowBukan blind retry command lama
Payment captured but response lostTidak blind retryButuh idempotency key dan reconciliation

Rule yang lebih kuat:

Retry hanya boleh dilakukan jika operasi punya known outcome policy.

Ada tiga outcome class:

Outcome classMeaningRetry strategy
Known failureSistem yakin operasi tidak terjadiRetry boleh jika transient
Known successSistem tahu operasi sudah terjadiJangan retry; return/replay success
Unknown outcomeSistem tidak tahu apakah side effect terjadiRetry hanya dengan idempotency key / query status / reconciliation

Unknown outcome adalah kasus paling berbahaya.

Contoh:

Service A mengirim command CapturePayment ke Payment Provider.
Provider berhasil capture.
Response timeout sebelum diterima A.
A retry tanpa idempotency key.
Provider capture dua kali.

Untuk command side-effectful, retry policy tidak bisa dipisahkan dari idempotency.


4. Retry Safe Operation Design

Operasi retry-safe biasanya memenuhi satu atau lebih kondisi berikut.

4.1 Natural Idempotency

Operasi yang hasil akhirnya sama meskipun dipanggil berkali-kali.

PUT /cases/CASE-123/assignee
{
  "assigneeId": "investigator-7"
}

Jika assignee sudah investigator-7, hasil akhirnya sama.

4.2 Idempotency Key

Client mengirim key unik untuk command.

POST /cases/CASE-123/escalations
Idempotency-Key: 3b2e6e9e-5d2a-4aa1-8f81-8d08ac8b33f0

Server menyimpan:

idempotency_key
request_hash
status
response_payload
created_at
expires_at

Jika request sama datang lagi, server replay response lama.

4.3 Version Guard

Command hanya valid terhadap expected version.

{
  "caseId": "CASE-123",
  "expectedVersion": 17,
  "decision": "ESCALATE"
}

Jika state sudah berubah, retry lama tidak diam-diam menimpa state baru.

4.4 External Operation Key

Untuk provider eksternal, selalu kirim business operation ID.

{
  "externalOperationId": "case-escalation-CASE-123-v17",
  "amount": 100000
}

Kalau response hilang, caller bisa query status berdasarkan operation ID.


5. Backoff: Jangan Retry Terlalu Cepat

Retry langsung berbahaya karena semua client yang gagal akan mencoba lagi seketika.

Bad pattern:

attempt 1 at t=0ms
attempt 2 at t=1ms
attempt 3 at t=2ms

Ini hampir sama dengan mengirim traffic tambahan saat downstream belum sempat pulih.

Backoff memberi jeda antar retry.

5.1 Fixed Backoff

100ms, 100ms, 100ms

Mudah, tapi semua client tetap sinkron.

5.2 Exponential Backoff

100ms, 200ms, 400ms, 800ms

Lebih baik, tetapi masih bisa sinkron jika semua client mulai pada waktu yang sama.

5.3 Capped Exponential Backoff

100ms, 200ms, 400ms, 800ms, 1000ms, 1000ms

Membatasi delay maksimum.

5.4 Exponential Backoff with Jitter

random(0, 100ms)
random(0, 200ms)
random(0, 400ms)
random(0, 800ms)

Jitter menyebarkan retry agar tidak terjadi synchronized wave.


6. Why Jitter Matters

Bayangkan 10.000 client menerima 503 pada waktu yang sama.

Tanpa jitter:

t=0s      10.000 failures
t=1s      10.000 retries
t=2s      10.000 retries
t=4s      10.000 retries

Downstream tidak pernah mendapat ruang pemulihan.

Dengan jitter:

t=0.0-1.0s    retries tersebar
t=1.0-2.0s    retries tersebar
t=2.0-4.0s    retries tersebar

Diagram:

Jitter bukan detail kecil. Ia adalah cara mencegah semua client bertindak sebagai satu gelombang traffic besar.


7. Backoff Algorithms in Java

7.1 Full Jitter

Formula umum:

cap = min(maxDelay, baseDelay * 2^attempt)
delay = random(0, cap)

Contoh Java:

import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;

public final class BackoffPolicy {
    private final Duration baseDelay;
    private final Duration maxDelay;

    public BackoffPolicy(Duration baseDelay, Duration maxDelay) {
        if (baseDelay.isNegative() || baseDelay.isZero()) {
            throw new IllegalArgumentException("baseDelay must be positive");
        }
        if (maxDelay.compareTo(baseDelay) < 0) {
            throw new IllegalArgumentException("maxDelay must be >= baseDelay");
        }
        this.baseDelay = baseDelay;
        this.maxDelay = maxDelay;
    }

    public Duration fullJitterDelay(int attemptNumber) {
        if (attemptNumber < 1) {
            throw new IllegalArgumentException("attemptNumber starts at 1");
        }

        long baseMillis = baseDelay.toMillis();
        long maxMillis = maxDelay.toMillis();

        long exponential;
        if (attemptNumber >= 30) {
            exponential = maxMillis;
        } else {
            exponential = baseMillis * (1L << (attemptNumber - 1));
        }

        long cap = Math.min(maxMillis, exponential);
        long delay = ThreadLocalRandom.current().nextLong(0, cap + 1);
        return Duration.ofMillis(delay);
    }
}

Gunakan ini untuk memahami konsep. Dalam production, biasanya policy diintegrasikan dengan library resilience, HTTP client, atau SDK yang sudah mendukung retry/backoff.


8. Retry Budget

Retry budget menjawab:

dari total traffic, berapa banyak retry yang boleh dibuat sebelum retry dianggap memperburuk kondisi?

Tanpa budget, retry bisa tak terkendali.

Contoh policy:

retry_attempts_per_minute <= 10% of original_requests_per_minute

Jika service menerima 10.000 request/minute, retry tambahan maksimum 1.000 attempt/minute.

Jika error melonjak dan retry attempt melebihi budget:

  1. hentikan retry;
  2. fail fast;
  3. return degraded response jika tersedia;
  4. biarkan downstream pulih.

Diagram:

Retry budget lebih penting daripada maxAttempts lokal karena ia membaca kondisi sistem secara agregat.


9. Layered Retry: Where Should Retry Live?

Retry dapat terjadi di banyak layer:

  1. browser/mobile client;
  2. API gateway;
  3. service client;
  4. SDK cloud provider;
  5. database driver;
  6. message broker;
  7. job scheduler;
  8. workflow engine.

Masalahnya bukan retry ada di banyak tempat. Masalahnya jika retry di banyak tempat tidak dikoordinasikan.

Architecture rule:

Dalam satu request path synchronous, tentukan retry owner utama. Layer lain harus punya retry minimal atau disabled kecuali ada alasan eksplisit.

Contoh policy:

LayerRetry policy
Mobile clientRetry only network failure, low attempts, user-visible
GatewayNo retry untuk non-idempotent POST
Service A client to BRetry 1-2 kali untuk safe/idempotent operation
SDK external paymentRetry hanya dengan idempotency key
DB driverRetry connection acquisition only, not whole transaction blindly

10. Java Implementation with Resilience4j Retry

Contoh konfigurasi Spring Boot style:

resilience4j:
  retry:
    instances:
      caseReadClient:
        max-attempts: 3
        wait-duration: 100ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2
        enable-randomized-wait: true
        randomized-wait-factor: 0.5
        retry-exceptions:
          - java.io.IOException
          - java.net.SocketTimeoutException
        ignore-exceptions:
          - com.example.caseapi.BusinessValidationException
          - com.example.caseapi.AuthorizationException

Catatan desain:

  1. max-attempts: 3 berarti total attempt, bukan tambahan retry di beberapa library.
  2. Jangan retry semua Exception.
  3. Pisahkan config per dependency, bukan satu global retry untuk semua call.
  4. Tambahkan timeout/deadline; retry tanpa timeout akan menggantung.
  5. Tambahkan metric tag per downstream.

Programmatic style:

import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;

import java.io.IOException;
import java.time.Duration;
import java.util.function.Supplier;

public final class CaseLookupClient {
    private final RemoteCaseApi api;
    private final Retry retry;

    public CaseLookupClient(RemoteCaseApi api) {
        this.api = api;
        RetryConfig config = RetryConfig.custom()
                .maxAttempts(3)
                .waitDuration(Duration.ofMillis(100))
                .retryExceptions(IOException.class)
                .ignoreExceptions(BusinessValidationException.class)
                .build();

        this.retry = Retry.of("caseLookupClient", config);
    }

    public CaseSnapshot getCase(String caseId) {
        Supplier<CaseSnapshot> supplier = Retry.decorateSupplier(
                retry,
                () -> api.fetchCase(caseId)
        );
        return supplier.get();
    }
}

Untuk command, jangan hanya memakai annotation tanpa semantic guard.

Bad:

@Retry(name = "default")
public EscalationResult escalate(EscalateCaseCommand command) {
    return remoteWorkflow.startEscalation(command);
}

Lebih aman:

public EscalationResult escalate(EscalateCaseCommand command) {
    IdempotencyKey key = command.idempotencyKey();

    if (!command.hasExpectedVersion()) {
        throw new IllegalArgumentException("Command must carry expectedVersion");
    }

    return retryableWorkflowStart.start(
            key,
            command.expectedVersion(),
            command
    );
}

Retry-safe command bukan dihasilkan oleh @Retry. Ia dihasilkan oleh contract: idempotency key, expected version, operation identity, timeout, dan outcome reconciliation.


11. HTTP Retry Policy

Untuk REST/HTTP client, retry policy harus membaca method, status, dan idempotency.

MethodDefault retry?Notes
GETYa, jika safePerhatikan stale/cache semantics
HEADYaSafe
PUTYa, jika idempotent by designGunakan version guard bila stateful
DELETEKadangIdempotent secara HTTP, tapi business side effect perlu dicek
POSTTidak defaultBoleh jika pakai idempotency key
PATCHTidak defaultPartial update sering tidak idempotent

Status handling:

public boolean shouldRetry(HttpAttempt attempt) {
    if (!attempt.isWithinDeadline()) return false;
    if (!attempt.operation().isRetrySafe()) return false;

    int status = attempt.statusCode();

    if (status == 408 || status == 429) return true;
    if (status == 500 || status == 502 || status == 503 || status == 504) return true;

    return attempt.exception() instanceof java.io.IOException;
}

Honor Retry-After untuk 429 atau 503 jika diberikan, tetapi tetap batasi dengan deadline caller.

actualDelay = min(retryAfter, remainingDeadline, maxRetryDelay)

12. Messaging Retry Is Different

Dalam messaging, retry bukan hanya “call again”. Ia mempengaruhi queue depth, consumer lag, ordering, dan poison message.

Ada tiga jenis retry:

TypeDescriptionUse case
Immediate retryConsumer mencoba lagi langsungTransient local error singkat
Delayed retryMessage dikirim ke retry topic/queue dengan delayDownstream temporary unavailable
Dead-letterMessage dipindah setelah batas retryPoison message / permanent failure

Flow:

Rules:

  1. Jangan block partition terlalu lama untuk satu message bermasalah.
  2. Jangan retry poison message selamanya.
  3. Simpan error reason dan stack trace ringkas di DLQ metadata.
  4. Buat re-drive tool dengan filter, bukan replay semua DLQ secara buta.
  5. Consumer harus idempotent karena broker dapat mengirim duplicate.

13. Retry Storm Failure Modes

13.1 Synchronized Client Retry

Semua client retry pada interval yang sama.

Mitigation:

  • jitter;
  • token bucket retry budget;
  • respect Retry-After;
  • load shedding upstream.

13.2 Layered Retry Amplification

Gateway, service, SDK, dan DB driver semua retry.

Mitigation:

  • single retry owner per path;
  • global architecture policy;
  • disable hidden retry where needed;
  • trace retry attempt count.

13.3 Retry After Timeout But Work Still Running

Caller timeout, retry, downstream masih memproses attempt pertama.

Mitigation:

  • idempotency key;
  • cancellation propagation jika didukung;
  • operation status query;
  • shorter server-side work timeout.

13.4 Retry Non-Idempotent Command

Command menghasilkan side effect ganda.

Mitigation:

  • no retry by default for unsafe command;
  • idempotency store;
  • expected version;
  • external operation ID.

13.5 Retry During Overload

Downstream sudah overload, retry menambah beban.

Mitigation:

  • circuit breaker;
  • adaptive retry budget;
  • rate limiter;
  • fail fast;
  • degraded mode.

14. Observability for Retry

Retry harus terlihat di telemetry.

Minimal metrics:

MetricMeaning
retry.attempts.totalTotal retry attempt
retry.exhausted.totalOperasi gagal setelah retry habis
retry.success_after_retry.totalBerhasil setelah retry
retry.skipped.not_retryable.totalTidak retry karena error tidak layak
retry.skipped.budget_exhausted.totalTidak retry karena budget habis
retry.delay.msDelay distribusi
downstream.status_codeStatus downstream
operation.idempotentApakah operation retry-safe

Structured log contoh:

{
  "event": "retry_attempt_scheduled",
  "service": "case-service",
  "dependency": "party-service",
  "operation": "getPartySnapshot",
  "attempt": 2,
  "maxAttempts": 3,
  "delayMs": 184,
  "reason": "HTTP_503",
  "correlationId": "corr-9b7"
}

Trace span attributes:

retry.attempt = 2
retry.max_attempts = 3
retry.delay_ms = 184
downstream.service = party-service
error.retryable = true

Tanpa observability, retry policy hanya keyakinan. Dengan observability, retry policy menjadi control loop yang bisa dikalibrasi.


15. Retry Policy Template

Gunakan template ini untuk setiap dependency.

dependency: party-service
operation: getPartySnapshot
interaction_type: synchronous-http
criticality: required-for-case-detail
semantic_safety:
  idempotent: true
  side_effect: false
time_budget:
  caller_deadline_ms: 800
  connect_timeout_ms: 100
  read_timeout_ms: 250
retry:
  max_attempts_total: 3
  backoff: exponential_full_jitter
  initial_delay_ms: 50
  max_delay_ms: 200
  retryable_status:
    - 408
    - 429
    - 502
    - 503
    - 504
  non_retryable_status:
    - 400
    - 401
    - 403
    - 404
budget:
  max_retry_ratio: 0.1
fallback:
  type: partial_response
  user_message: "Party details temporarily unavailable"
observability:
  emit_attempt_metric: true
  tag_dependency: true
  trace_attempts: true
owner: case-platform-team

16. Review Checklist

Sebelum mengaktifkan retry, tanyakan:

  • Apakah operation idempotent secara teknis dan bisnis?
  • Apakah ada idempotency key untuk command side-effectful?
  • Apakah retry punya max attempts?
  • Apakah retry punya deadline end-to-end?
  • Apakah backoff memakai jitter?
  • Apakah retry menghormati 429, 503, dan Retry-After?
  • Apakah ada retry budget?
  • Apakah layered retry sudah dipetakan?
  • Apakah hidden retry di SDK/driver diketahui?
  • Apakah retry attempt terlihat di metrics/logs/traces?
  • Apakah ada fallback atau graceful failure?
  • Apakah retry bisa dihentikan saat downstream overload?

17. Common Anti-Patterns

Anti-pattern 1 — Global Retry for Everything

retry:
  maxAttempts: 5
  retryAllExceptions: true

Ini buruk karena validation error, authorization error, dan business conflict ikut di-retry.

Anti-pattern 2 — Retry POST Without Idempotency

POST /payments/capture

Tanpa Idempotency-Key, caller tidak tahu apakah capture pertama sudah berhasil.

Anti-pattern 3 — Timeout Longer Than User Deadline

UI deadline: 2s
Service A timeout to B: 5s
Retry attempts: 3

Policy ini mustahil memenuhi user deadline.

Anti-pattern 4 — Retry in Every Layer

Client x3
Gateway x3
Service x3
SDK x3

Hasilnya adalah amplification, bukan resilience.

Anti-pattern 5 — No Jitter

Backoff tanpa jitter masih bisa menghasilkan retry wave.


18. Mini Case Study: Case Detail Page

User membuka detail case.

case-service perlu data dari:

  1. party-service;
  2. evidence-service;
  3. decision-service.

Tidak semua dependency punya criticality yang sama.

DependencyCriticalityRetryFallback
case DBRequiredLocal DB retry minimalFail page
party-serviceRequired for party panel2 attempts with jitterShow unavailable panel
evidence-serviceOptional summary1 retryHide summary temporarily
decision-serviceRequired for active decision2 attemptsShow stale cached snapshot with warning

Architecture insight:

Retry policy harus mengikuti user journey dan business criticality, bukan disalin dari template global.


19. Exercises

  1. Ambil satu endpoint di sistemmu yang melakukan fan-out ke 3 dependency. Hitung worst-case attempt jika tiap layer retry 3 kali.
  2. Pilih satu command non-idempotent. Desain idempotency key dan outcome reconciliation-nya.
  3. Buat retry policy YAML untuk dependency eksternal yang kadang mengembalikan 429.
  4. Tambahkan metric dan log schema untuk retry attempt.
  5. Tentukan layer mana yang menjadi retry owner untuk satu user journey penting.

20. Key Takeaways

  • Retry adalah load multiplier, bukan solusi gratis.
  • Retry hanya aman jika operasi retry-safe secara bisnis.
  • Backoff mengurangi tekanan; jitter mencegah synchronized retry wave.
  • Retry harus bounded oleh attempts, deadline, dan retry budget.
  • Layered retry tanpa koordinasi dapat menciptakan retry storm.
  • Command side-effectful butuh idempotency key, expected version, atau operation status query.
  • Retry policy harus observable dan per-dependency.

Part berikutnya membahas Circuit Breaker, Bulkhead, and Rate Limiter: tiga guardrail utama untuk menghentikan kegagalan agar tidak menyebar ke seluruh topology service.


Referensi

  • AWS Builders' Library — Timeouts, retries, and backoff with jitter.
  • Google SRE Book — Addressing Cascading Failures.
  • Google SRE Book — Production Services Best Practices.
  • RFC 9110 — HTTP Semantics.
  • Resilience4j Documentation — Retry module and Spring Boot integration.
Lesson Recap

You just completed lesson 41 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.