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.
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:
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 detikMari kita telaah baris-baris kode di atas:
- Baris 4 (parameter fungsi):
dasar=0.5adalah jeda awal setengah detik danmaksimum=30.0membatasi jeda agar tidak tumbuh tak terkendali pada percobaan ke-10 dan seterusnya. - Baris 5 (
jeda_dasar = min(...)): rumusdasar * 2 ** (percobaan - 1)menggandakan jeda setiap percobaan, laluminmenjepitnya di nilaimaksimum. - 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. - Baris 10 (
random.Random(42)): generator acak berbibit tetap dipakai agar contoh ini menghasilkan output yang sama setiap dijalankan; di produksi cukup pakairandom.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.
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:
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: 3Mari kita telaah bagian-bagian kuncinya:
- Baris 20-27 (pembatasan di server): penghitung
hitungan["permintaan"]membuat dua permintaan pertama dibalas429, dan hanya permintaan pertama yang membawa headerRetry-After: 1— sehingga klien dipaksa menempuh kedua jalur jeda. - Baris 53-55 (keberhasilan): status
200langsung mengembalikan data; tidak perlu menunggu atau mencoba lagi. - Baris 56-65 (penanganan 429): header
Retry-Afterdicek lebih dulu dan dipatuhi apa adanya; bila header absen, klien memanggildurasi_backoff()lalutime.sleep()sebelumcontinuemengulang permintaan. - Baris 66 (
jawab.raise_for_status()): status selain 200 dan 429 — misalnya500atau401— langsung dimunculkan sebagai pengecualian, karena menunggu tidak akan memperbaiki galat semacam itu. - Baris 67 (
raise RuntimeError): setelahmaks_percobaanhabis, 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.
| Keadaan | Perilaku |
|---|---|
| tertutup | permintaan berjalan normal |
| terbuka | permintaan ditolak seketika tanpa memanggil API |
| setengah terbuka | satu 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.