API publik hampir selalu memasang kuota: jumlah permintaan maksimum per detik, per menit, atau per jam. Melebihi kuota tidak berarti permintaan kita salah, hanya terlalu cepat, sehingga server membalas 429 Too Many Requests beserta petunjuk kapan boleh kembali. Pipeline yang mengabaikan sinyal ini akan terus mengebani server, memperparah kemacetan, dan berisiko membuat alamat kita diblokir lebih lama. Pada bagian ini klien belajar menunggu dengan sopan.
Perilaku 429 dari Server #
Fake API bisa disuruh membalas 429 untuk beberapa permintaan pertama. Jalankan server dengan opsi --rate-limit-first 1, lalu periksa respons mentahnya:
python3 -m src.fake_api --rate-limit-first 1curl -si http://127.0.0.1:8765/api/v1/healthOutput yang Dihasilkan:
HTTP/1.1 429 Too Many Requests
Server: TokoKitaAPI/1.0 Python/3.12.15
Date: Sat, 17 Oct 2026 07:43:58 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 66
Retry-After: 1
{"error": "rate_limited", "message": "Terlalu banyak permintaan."}Baris pertama menegaskan status 429, sedangkan header Retry-After: 1 menyatakan bahwa klien boleh mencoba lagi setelah satu detik. Header Retry-After pada praktiknya berisi jumlah detik; sebagian API juga mengirim format tanggal HTTP, tetapi parser kita cukup menangani format detik karena itulah yang dikirim TokoKita.
Menghormati Retry-After #
Cabang rate limit di get_json sudah kita lihat pada Bagian 2; kini kita bedah mesin tunggunya. Dua fungsi berikut bekerja sama: parse_retry_after membaca header, lalu _tunggu_atau_gagal menentukan durasi tunggu:
def parse_retry_after(nilai):
"""Konversi header ``Retry-After`` berisi detik ke float.
Mengembalikan ``None`` bila header absen atau isinya tidak bisa dibaca,
sehingga pemanggil jatuh ke durasi backoff biasa.
"""
if nilai is None:
return None
try:
detik = float(nilai)
except (TypeError, ValueError):
return None
return detik if detik >= 0 else None def _tunggu_atau_gagal(self, attempt, url, alasan, retry_after=None):
"""Tunggu sesuai kebijakan, atau angkat galat bila budget percobaan habis.
Respons 429/5xx yang menyertakan header ``Retry-After`` dihormati
(dibatasi ``max_retry_after``); tanpa header tersebut klien memakai
exponential backoff.
"""
if attempt >= self.policy.max_retries:
raise TransientApiError(
f"{alasan} pada {url}; batas {self.policy.max_retries} "
f"percobaan ulang tercapai"
)
if retry_after is not None:
detik = min(retry_after, self.policy.max_retry_after)
else:
detik = backoff_delay(attempt, self.policy)
logger.warning(
"percobaan %d gagal (%s); tunggu %.2f detik lalu ulangi",
attempt + 1, alasan, detik,
)
time.sleep(detik)Mari kita telaah baris-baris kode di atas:
- Baris 45-50 (
parse_retry_after): Header absen atau berisi teks tidak terbaca menghasilkanNone, bukan galat; klien cukup jatuh ke durasi backoff biasa. - Baris 69-73 (
attempt >= max_retries): Menunggu tidak boleh berlangsung selamanya; bila anggaran percobaan habis,TransientApiErrormengangkat kegagalan ke pemanggil. - Baris 74-75 (
min(retry_after, max_retry_after)): NilaiRetry-Afterdihormati tetapi dibatasi. Server yang keliru mengirimRetry-After: 9999tidak boleh membuat pipeline tertidur berjam-jam. - Baris 77 (
backoff_delay): Tanpa header, durasi tunggu dihitung dari kebijakan exponential backoff yang dibedah pada Bagian 5. - Baris 78-82 (
logger.warning+time.sleep): Setiap penundaan dicatat sebelum dijalankan, sehingga log run memperlihatkan kapan dan berapa lama pipeline menunggu.
Klien Pulih dari 429 #
Dengan server --rate-limit-first 1 yang baru dijalankan, klien menghadapi 429 pada permintaan pertama, menunggu sesuai Retry-After, lalu berhasil:
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.5)
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 429); tunggu 1.00 detik lalu ulangi
Respons akhir: {'status': 'ok'}Satu baris peringatan memperlihatkan keseluruhan kejadian: percobaan pertama ditolak, klien menunggu 1,00 detik sesuai header, dan percobaan kedua berhasil. Penjelajahan tiga halaman dari Bagian 3 otomatis mendapat perlindungan yang sama karena seluruh permintaan melewati get_json.
Uji Rate Limit #
Tes di tests/test_rate_limit.py mengunci perilaku ini tanpa benar-benar menunggu: monkeypatch mengganti time.sleep dengan perekam durasi. Pengecekan tundaan == [1.0] membuktikan klien menunggu persis selama nilai Retry-After:
def test_klien_menghormati_retry_after(monkeypatch):
with FakeTokoKitaAPI(rate_limit_first=1) as api:
tundaan = []
monkeypatch.setattr("src.client.time.sleep",
lambda detik: tundaan.append(detik))
klien = ApiClient(api.url, policy=RetryPolicy(
max_retries=2, backoff_base=0.01, jitter=0.0))
payload = klien.get_json("/api/v1/health")
assert payload == {"status": "ok"}
assert api.status_log() == [429, 200]
assert tundaan == [1.0]python3 -m pytest tests/test_rate_limit.py -qOutput yang Dihasilkan:
......... [100%]
9 passed in 2.14sSembilan tes mencakup parsing header untuk berbagai bentuk nilai, pembatasan max_retry_after, kegagalan saat rate limit tak kunjung pulih, hingga penjelajahan pagination yang melewati satu 429. Strategi lengkap menunggu dan mengulang dibahas pada chapter Rate Limiting dan Retries. Batas kuota kini kita hormati; tinggal satu jenis galat sementara yang belum punya strategi tunggu yang baik, yaitu galat 5xx, yang kita tangani dengan exponential backoff pada bagian berikutnya. Rujukan penggunaan header respons tersedia di dokumentasi requests
.