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:

bash
python3 -m src.fake_api --rate-limit-first 1
bash
curl -si http://127.0.0.1:8765/api/v1/health

Output yang Dihasilkan:

text
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:

python
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
python
    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:

  1. Baris 45-50 (parse_retry_after): Header absen atau berisi teks tidak terbaca menghasilkan None, bukan galat; klien cukup jatuh ke durasi backoff biasa.
  2. Baris 69-73 (attempt >= max_retries): Menunggu tidak boleh berlangsung selamanya; bila anggaran percobaan habis, TransientApiError mengangkat kegagalan ke pemanggil.
  3. Baris 74-75 (min(retry_after, max_retry_after)): Nilai Retry-After dihormati tetapi dibatasi. Server yang keliru mengirim Retry-After: 9999 tidak boleh membuat pipeline tertidur berjam-jam.
  4. Baris 77 (backoff_delay): Tanpa header, durasi tunggu dihitung dari kebijakan exponential backoff yang dibedah pada Bagian 5.
  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:

bash
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)
PY

Output yang Dihasilkan:

text
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:

python
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]
bash
python3 -m pytest tests/test_rate_limit.py -q

Output yang Dihasilkan:

text
.........                                                                [100%]
9 passed in 2.14s

Sembilan 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 .