Server sesekali mengalami galat 500, koneksi terputus di tengah respons, atau proses restart ketika permintaan kita sedang diproses. Galat-galat tersebut sifatnya sementara: permintaan yang sama besar kemungkinan berhasil bila diulang beberapa saat kemudian. Namun mengulang seketika justru menumpukkan permintaan pada server yang sedang bergulat dengan masalah, terutama bila puluhan klien melakukan hal yang sama. Pada bagian ini kita menerapkan exponential backoff dengan jitter di src/retry.py.
Kebijakan Percobaan Ulang #
Seluruh parameter retry dikumpulkan dalam satu dataclass agar mudah dibaca dari config/pipeline.yaml:
@dataclass
class RetryPolicy:
"""Parameter percobaan ulang yang dipakai ApiClient.
max_retries: jumlah percobaan ulang setelah permintaan pertama.
backoff_base: durasi tunggu awal dalam detik.
backoff_cap: batas atas durasi tunggu agar pipeline tidak menggantung.
jitter: fraksi pengacakan durasi (0.25 berarti +-25%).
max_retry_after: nilai Retry-After terbesar yang mau kita hormati.
"""
max_retries: int = 3
backoff_base: float = 0.5
backoff_cap: float = 30.0
jitter: float = 0.25
max_retry_after: float = 60.0Perhitungan durasinya berada di fungsi murni backoff_delay:
def backoff_delay(attempt, policy=None, rng=random.random):
"""Durasi tunggu sebelum percobaan ulang ke-``attempt`` (mulai dari 0).
Durasi dasar tumbuh eksponensial (base * 2^attempt) sampai ``backoff_cap``,
lalu dikalikan faktor jitter agar banyak klien tidak mencoba ulang pada
detik yang sama.
"""
policy = policy or RetryPolicy()
dasar = min(policy.backoff_base * (2 ** attempt), policy.backoff_cap)
faktor = 1.0 + policy.jitter * (2.0 * rng() - 1.0)
return dasar * faktorMari kita telaah baris-baris kode di atas:
- Baris 34 (
min(base * 2**attempt, cap)): Durasi dasar berlipat dua pada setiap kegagalan, 0,5 detik lalu 1, 2, 4, 8 detik, dan berhenti dibackoff_cap30 detik. - Baris 35 (
faktor = 1.0 + jitter * ...): Jitter menggeser durasi secara acak di kisaran tertentu; denganjitter=0.25, durasi bergerak antara 75% hingga 125% dari durasi dasar. - Baris 36 (
return dasar * faktor): Fungsi tidak menyentuhtime.sleepsama sekali; fungsi hanya menghitung durasi, sehingga mudah diuji tanpa menunggu waktu nyata. - Parameter
rng: Pembangkit acak bisa disuntik, dan tes memakairngkonstan untuk memeriksa batas atas serta batas bawah durasi.
Dengan nilai bawaan tersebut, tabel durasi tunggu untuk empat percobaan pertama terlihat sebagai berikut:
| Percobaan ulang | Durasi dasar | Kisaran dengan jitter 25% |
|---|---|---|
| ke-1 | 0,5 detik | 0,38 - 0,63 detik |
| ke-2 | 1,0 detik | 0,75 - 1,25 detik |
| ke-3 | 2,0 detik | 1,50 - 2,50 detik |
| ke-4 | 4,0 detik | 3,00 - 5,00 detik |
Jitter ada untuk kasus banyak klien: bila seratus pipeline mengalami galat yang sama dan semuanya menunggu tepat 2,0 detik, seratus permintaan tiba kembali pada milidetik yang sama dan mengulang kemacetan. Pengacakan kecil meratakan beban pemulihan.
Pulih dari Galat 500 #
Jalankan server dengan --fail-first 2 sehingga dua permintaan pertama dibalas 500, lalu perhatikan klien bekerja:
python3 - <<'PY'
import logging
from src.client import ApiClient
from src.retry import RetryPolicy
logging.basicConfig(level=logging.WARNING,
format="%(levelname)s %(name)s: %(message)s")
policy = RetryPolicy(max_retries=3, backoff_base=0.2, backoff_cap=5.0, jitter=0.25)
klien = ApiClient("http://127.0.0.1:8765", timeout=5.0, policy=policy)
payload = klien.get_json("/api/v1/health")
print("Respons akhir:", payload)
PYOutput yang Dihasilkan:
WARNING ingestion.client: percobaan 1 gagal (HTTP 500); tunggu 0.21 detik lalu ulangi
WARNING ingestion.client: percobaan 2 gagal (HTTP 500); tunggu 0.37 detik lalu ulangi
Respons akhir: {'status': 'ok'}Durasi tunggu 0,21 dan 0,37 detik adalah durasi dasar 0,2 detik yang telah dikenai jitter, sehingga angka pada mesin Anda sedikit berbeda setiap eksekusi. Percobaan ketiga berhasil dan pemanggil menerima payload seolah tidak pernah ada gangguan.
Ketika Anggaran Habis #
Server dengan --fail-first 9 tidak pernah pulih dalam jangka percobaan kita. Klien menyerah dengan galat yang jelas alasan dan jumlah percobaannya:
python3 - <<'PY'
import logging
from src.client import ApiClient
from src.retry import RetryPolicy
logging.basicConfig(level=logging.WARNING,
format="%(levelname)s %(name)s: %(message)s")
policy = RetryPolicy(max_retries=3, backoff_base=0.2, backoff_cap=5.0, jitter=0.25)
klien = ApiClient("http://127.0.0.1:8765", timeout=5.0, policy=policy)
payload = klien.get_json("/api/v1/health")
print("Respons akhir:", payload)
PYOutput yang Dihasilkan:
WARNING ingestion.client: percobaan 1 gagal (HTTP 500); tunggu 0.22 detik lalu ulangi
WARNING ingestion.client: percobaan 2 gagal (HTTP 500); tunggu 0.36 detik lalu ulangi
WARNING ingestion.client: percobaan 3 gagal (HTTP 500); tunggu 0.93 detik lalu ulangi
Traceback (most recent call last):
File "<stdin>", line 11, in <module>
File "/home/z/my-project/tutorial/content/data-engineering/proyek/pipeline-ingestion-api/src/client.py", line 49, in get_json
self._tunggu_atau_gagal(attempt, url, f"HTTP {respons.status_code}",
File "/home/z/my-project/tutorial/content/data-engineering/proyek/pipeline-ingestion-api/src/client.py", line 70, in _tunggu_atau_gagal
raise TransientApiError(
src.client.TransientApiError: HTTP 500 pada http://127.0.0.1:8765/api/v1/health; batas 3 percobaan ulang tercapaiTiga peringatan pertama memperlihatkan percobaan yang makin jarang karena durasi tunggu tumbuh; empat baris terakhir adalah traceback lengkap dari galat final. TransientApiError sengaja dibedakan dari ApiError permanen: penjadwal pipeline boleh menjalankan ulang seluruh run nanti, sedangkan galat permanen menuntut perbaikan kode atau konfigurasi terlebih dahulu. Pola kegagalan semacam ini dibahas pada chapter Menangani Kegagalan Ingestion.
Uji Retry #
Tes di tests/test_retry.py memeriksa kurva durasi dan perilaku nyata terhadap server flaky:
def test_backoff_tumbuh_eksponensial_lalu_berhenti_di_cap():
policy = kebijakan()
delays = [backoff_delay(attempt, policy) for attempt in range(8)]
assert delays == [0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0]
def test_jitter_ada_di_kisaran_terbatas():
policy = kebijakan(jitter=0.5)
kecil = backoff_delay(0, policy, rng=lambda: 0.0)
besar = backoff_delay(0, policy, rng=lambda: 1.0)
# jitter 0.5 berarti durasi bergerak di kisaran +-50% dari durasi dasar.
assert kecil == pytest.approx(0.5 * 0.5)
assert besar == pytest.approx(0.5 * 1.5)Tes pertama memakai jitter=0.0 sehingga deret durasi bisa dibandingkan persis, termasuk berhenti di nilai cap. Tes kedua menyuntik rng konstan pada dua ujungnya untuk membuktikan jitter tidak pernah keluar dari kisaran yang dijanjikan. Tes ketiga di berkas yang sama memanfaatkan status_log server flaky untuk membuktikan urutan kejadian [500, 500, 200] yang sesungguhnya terjadi di kabel jaringan.
python3 -m pytest tests/test_retry.py -qOutput yang Dihasilkan:
.... [100%]
4 passed in 1.13sKlien kita kini tahan banting terhadap tiga gangguan sekaligus: kuota, galat server, dan gangguan jaringan. Namun semua ketangguhan tersebut percuma bila payload yang diterima berubah bentuk tanpa sepengetahuan kita; bagian berikutnya memasang pagar schema sebelum data dipercaya. Perilaku time.sleep yang dipakai penundaan terdokumentasi di dokumentasi modul time Python
.