Setelah klien pagination pada Chapter Menangani Pagination API berjalan lancar, pipeline kita bisa mengirim ribuan permintaan dalam beberapa menit. Penyedia API melindungi dirinya dengan rate limit: begitu kuota permintaan terlampaui, server membalas status 429 Too Many Requests dan data tidak dikirim. Pipeline yang buruk langsung menyerah atau, lebih buruk lagi, mengirim ulang secepatnya dan memperberat penolakan. Pipeline yang baik justru melambat sejenak, mencoba lagi dengan jeda yang makin panjang, dan tahu kapan harus berhenti. Chapter ini membedah keempat alat tersebut satu per satu.

Status 429 dan Header Retry-After #

Status 429 bukan galat pada kode kita, melainkan sinyal “coba lagi nanti”. Banyak API menyertakan header Retry-After pada respons 429, berisi jumlah detik yang harus ditunggu sebelum permintaan berikutnya. Aturan mainnya sederhana: jika header tersebut ada, patuhi nilainya, karena header itulah keterangan paling akurat tentang kapan kuota pulih. Sebagian API juga menyertakan header X-RateLimit-Remaining berisi sisa kuota, sehingga pipeline bisa melambat lebih awal sebelum penolakan terjadi. Seluruh header respons bisa diakses lewat jawab.headers pada pustaka requests, seperti didokumentasikan di https://requests.readthedocs.io/ . Jika header tidak ada, kita menghitung jeda sendiri dengan exponential backoff.

Exponential Backoff dengan Jitter #

Exponential backoff membuat jeda tumbuh dua kali lipat setiap percobaan: 0,5 detik, lalu 1 detik, 2 detik, 4 detik, dan seterusnya, sampai nilai maksimum. Jitter menambahkan unsur acak pada jeda tersebut. Tanpa jitter, ribuan worker yang sama-sama ditolak pukul 10:00:00 akan mencoba lagi serentak pukul 10:00:01 dan ditolak lagi; jitter menyerakkan waktu coba ulang sehingga tidak ada kemacetan serentak.

python
import random


def durasi_backoff(percobaan, rng, dasar=0.5, maksimum=30.0):
    jeda_dasar = min(dasar * (2 ** (percobaan - 1)), maksimum)
    return jeda_dasar * (0.5 + rng.random())


rng = random.Random(42)
for percobaan in range(1, 6):
    print(f"percobaan {percobaan}: tunggu {durasi_backoff(percobaan, rng):.2f} detik")

Output yang Dihasilkan:

text
percobaan 1: tunggu 0.57 detik
percobaan 2: tunggu 0.53 detik
percobaan 3: tunggu 1.55 detik
percobaan 4: tunggu 2.89 detik
percobaan 5: tunggu 9.89 detik

Mari kita telaah baris-baris kode di atas:

  1. Baris 4 (parameter fungsi): dasar=0.5 adalah jeda awal setengah detik dan maksimum=30.0 membatasi jeda agar tidak tumbuh tak terkendali pada percobaan ke-10 dan seterusnya.
  2. Baris 5 (jeda_dasar = min(...)): rumus dasar * 2 ** (percobaan - 1) menggandakan jeda setiap percobaan, lalu min menjepitnya di nilai maksimum.
  3. Baris 6 (return jeda_dasar * (0.5 + rng.random())): faktor acak di rentang 0,5 sampai 1,5 dikalikan ke jeda dasar — variasi yang dikenal sebagai full jitter karena jeda akhir bisa berada di mana pun pada rentang tersebut.
  4. Baris 10 (random.Random(42)): generator acak berbibit tetap dipakai agar contoh ini menghasilkan output yang sama setiap dijalankan; di produksi cukup pakai random.random() tanpa bibit.

Contoh: API Tiruan yang Membatasi Dua Permintaan Pertama #

Sekarang kita gabungkan semuanya. API tiruan lokal menolak dua permintaan pertama dengan 429 — yang pertama menyertakan Retry-After: 1, yang kedua tidak — lalu mengizinkan permintaan ketiga. Klien membaca header lebih dulu, jatuh ke backoff bila header absen, dan berhenti setelah batas percobaan.

python
import json
import random
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import requests

DATA_PESANAN = [
    {"id": 1, "pelanggan": "Budi", "total": 150000},
    {"id": 2, "pelanggan": "Siti", "total": 275000},
    {"id": 3, "pelanggan": "Dewi", "total": 99000},
]

hitungan = {"permintaan": 0}


class HandlerAPI(BaseHTTPRequestHandler):
    def do_GET(self):
        hitungan["permintaan"] += 1
        if hitungan["permintaan"] <= 2:
            # dua permintaan pertama dibatasi (429)
            self.send_response(429)
            if hitungan["permintaan"] == 1:
                self.send_header("Retry-After", "1")
            self.end_headers()
            return
        payload = json.dumps({"data": DATA_PESANAN}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def log_message(self, *args):
        pass


server = ThreadingHTTPServer(("127.0.0.1", 0), HandlerAPI)
threading.Thread(target=server.serve_forever, daemon=True).start()
base_url = f"http://127.0.0.1:{server.server_address[1]}"


def durasi_backoff(percobaan, rng, dasar=0.5, maksimum=30.0):
    jeda_dasar = min(dasar * (2 ** (percobaan - 1)), maksimum)
    return jeda_dasar * (0.5 + rng.random())


def ambil_dengan_retry(url, maks_percobaan=5):
    rng = random.Random(7)
    for percobaan in range(1, maks_percobaan + 1):
        jawab = requests.get(url)
        if jawab.status_code == 200:
            print(f"percobaan {percobaan}: 200 -> data diterima")
            return jawab.json()
        if jawab.status_code == 429:
            if "Retry-After" in jawab.headers:
                tunggu = float(jawab.headers["Retry-After"])
                sumber_jeda = "header Retry-After"
            else:
                tunggu = durasi_backoff(percobaan, rng)
                sumber_jeda = "backoff eksponensial + jitter"
            print(f"percobaan {percobaan}: 429 -> tunggu {tunggu:.2f} detik ({sumber_jeda})")
            time.sleep(tunggu)
            continue
        jawab.raise_for_status()
    raise RuntimeError("permintaan gagal setelah batas percobaan")


hasil = ambil_dengan_retry(f"{base_url}/pesanan")
print(f"total pesanan diterima: {len(hasil['data'])}")
server.shutdown()

Output yang Dihasilkan:

text
percobaan 1: 429 -> tunggu 1.00 detik (header Retry-After)
percobaan 2: 429 -> tunggu 0.82 detik (backoff eksponensial + jitter)
percobaan 3: 200 -> data diterima
total pesanan diterima: 3

Mari kita telaah bagian-bagian kuncinya:

  1. Baris 20-27 (pembatasan di server): penghitung hitungan["permintaan"] membuat dua permintaan pertama dibalas 429, dan hanya permintaan pertama yang membawa header Retry-After: 1 — sehingga klien dipaksa menempuh kedua jalur jeda.
  2. Baris 53-55 (keberhasilan): status 200 langsung mengembalikan data; tidak perlu menunggu atau mencoba lagi.
  3. Baris 56-65 (penanganan 429): header Retry-After dicek lebih dulu dan dipatuhi apa adanya; bila header absen, klien memanggil durasi_backoff() lalu time.sleep() sebelum continue mengulang permintaan.
  4. Baris 66 (jawab.raise_for_status()): status selain 200 dan 429 — misalnya 500 atau 401 — langsung dimunculkan sebagai pengecualian, karena menunggu tidak akan memperbaiki galat semacam itu.
  5. Baris 67 (raise RuntimeError): setelah maks_percobaan habis, klien menyerah secara eksplisit agar pemanggil bisa mencatat kegagalan, bukan berputar diam-diam.

Circuit Breaker #

Retries mengatasi gangguan singkat, tetapi ketika API mati total selama satu jam, ribuan worker yang terus mencoba ulang hanya membuang sumber daya. Circuit breaker memutus arus tersebut dengan tiga keadaan.

KeadaanPerilaku
tertutuppermintaan berjalan normal
terbukapermintaan ditolak seketika tanpa memanggil API
setengah terbukasatu permintaan percobaan dikirim untuk menguji pemulihan

Setelah sejumlah kegagalan beruntun, breaker pindah ke keadaan terbuka selama masa jeda, lalu setengah terbuka untuk menguji: jika percobaan berhasil, breaker kembali tertutup; jika gagal, breaker terbuka lagi. Ambang batas dan masa jeda dikonfigurasi per API — lima kegagalan beruntun dengan jeda 60 detik adalah titik awal yang lazim. Library seperti tenacity di Python menyediakan implementasi siap pakai untuk pola ini.

Retry berarti satu permintaan bisa terkirim lebih dari sekali, dan satu event dari message queue bisa diproses lebih dari sekali. Jika pipeline kita tidak siap menghadapi pengulangan, baris pesanan akan tampil ganda di warehouse. Solusinya adalah idempotency, bahasan Chapter Idempotent Ingestion.