Kontrak API sudah jelas; sekarang kita tulis sisi konsumen. Klien HTTP adalah satu-satunya bagian pipeline yang berbicara dengan dunia luar, sehingga klien menentukan seberapa andal keseluruhan pipeline. Pada bagian ini kita membangun ApiClient di src/client.py dengan tiga prinsip: koneksi dipakai ulang lewat Session, setiap permintaan wajib memiliki timeout, dan galat dibedakan menjadi galat permanen versus galat sementara.

Klien dengan Session #

Konstruktor ApiClient menyiapkan seluruh kebutuhan hidup klien sejak awal:

python
class ApiClient:
    """Klien GET dengan session koneksi dan kebijakan retry bawaan."""

    def __init__(self, base_url, *, timeout=10.0, policy=None, session=None):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.policy = policy or RetryPolicy()
        self.session = session or requests.Session()
        self.session.headers.update({
            "Accept": "application/json",
            "User-Agent": "ingestion-pipeline/1.0",
        })

Mari kita telaah baris-baris kode di atas:

  1. Baris 26 (base_url.rstrip("/")): Garis miring di akhir alamat dibuang agar penyambungan base_url + path selalu menghasilkan URL yang benar.
  2. Baris 27 (self.timeout = timeout): Nilai timeout disimpan dan dipakai pada setiap permintaan; tidak ada satu pun panggilan get tanpa batas waktu.
  3. Baris 28 (policy or RetryPolicy()): Kebijakan retry bisa disuntik dari luar untuk keperluan pengujian, dengan nilai bawaan yang masuk akal.
  4. Baris 29 (requests.Session()): Session menyimpan koneksi TCP sehingga permintaan kedua dan seterusnya tidak perlu melakukan handshake ulang; session juga menampung header tetap.
  5. Baris 30-33 (headers.update): Header Accept dan User-Agent dikirim otomatis pada semua permintaan, kebiasaan yang baik agar server mengenali konsumen.

Ambil Data Nyata #

Dengan server dari Bagian 1 masih berjalan, kita bisa mencoba klien terhadap API lokal. Skrip singkat berikut dijalankan dari akar bundle:

bash
python3 - <<'PY'
import logging

from src.client import ApiClient

logging.basicConfig(level=logging.INFO,
                    format="%(levelname)s %(name)s: %(message)s")

klien = ApiClient("http://127.0.0.1:8765", timeout=5.0)
payload = klien.get_json("/api/v1/pesanan", params={"per_page": 3})
print("baris diterima  :", len(payload["data"]))
print("halaman         :", payload["meta"]["page"], "dari", payload["meta"]["total_pages"])
print("pesanan pertama :", payload["data"][0]["pelanggan"], "-", payload["data"][0]["email"])
PY

Output yang Dihasilkan:

text
baris diterima  : 3
halaman         : 1 dari 46
pesanan pertama : Budi Santoso - [email protected]

Satu panggilan get_json mengembalikan payload Python yang sudah terurai, sehingga pemanggil tidak perlu menyentuh status_code atau json() sama sekali.

Klasifikasi Galat #

API bisa menolak permintaan karena banyak hal, dan reaksi klien harus mengikuti penyebabnya. Klasifikasi berikut menjadi dasar seluruh penanganan galat di proyek ini:

KondisiSifat galatTindakan klien
200BerhasilKembalikan payload JSON
429Sementara (kuota)Tunggu sesuai Retry-After, lalu ulangi
5xxSementara (server)Exponential backoff, lalu ulangi
Timeout / koneksi gagalSementara (jaringan)Exponential backoff, lalu ulangi
4xx lainnyaPermanenAngkat ApiError tanpa percobaan ulang

Logika tersebut hidup di get_json, satu-satunya pintu keluar permintaan:

python
    def get_json(self, path, params=None):
        """Ambil satu endpoint dan kembalikan payload JSON yang sudah terurai."""
        url = f"{self.base_url}{path}"
        for attempt in range(self.policy.max_retries + 1):
            try:
                respons = self.session.get(url, params=params, timeout=self.timeout)
            except requests.RequestException as galat:
                self._tunggu_atau_gagal(attempt, url, f"galat koneksi: {galat}")
                continue

            if respons.status_code == 200:
                return respons.json()
            if respons.status_code == 429 or respons.status_code >= 500:
                retry_after = parse_retry_after(respons.headers.get("Retry-After"))
                self._tunggu_atau_gagal(attempt, url, f"HTTP {respons.status_code}",
                                        retry_after=retry_after)
                continue
            raise ApiError(
                f"HTTP {respons.status_code} dari {respons.url}: {respons.text[:200]}"
            )

Mari kita telaah baris-baris kode di atas:

  1. Baris 38 (for attempt in range(...)): Permintaan pertama ditambah max_retries percobaan ulang membentuk anggaran percobaan; setiap iterasi adalah satu kesempatan.
  2. Baris 40-42 (except requests.RequestException): Gangguan jaringan seperti koneksi ditolak atau timeout ditangkap sebagai galat sementara, bukan langsung menggagalkan run.
  3. Baris 45-46 (status_code == 200): Hanya status 200 dianggap berhasil; payload langsung dikembalikan sebagai dict.
  4. Baris 47-51 (429 atau >= 500): Kedua kelompok status ini layak diulang; header Retry-After diurai lebih dulu jika ada.
  5. Baris 52-54 (raise ApiError): Status 4xx lain berarti permintaan kita salah, dan mengulang permintaan yang sama hanya membuang kuota.

Uji Klien #

Berbagai perilaku klien dikunci di tests/test_client.py. Dua tes berikut memastikan header benar-benar terkirim dan galat permanen tidak diulang:

python
def test_header_accept_dikirim(klien, fake_api):
    klien.get_json("/api/v1/health")
    assert fake_api.last_headers["Accept"] == "application/json"
    assert fake_api.last_headers["User-Agent"].startswith("ingestion-pipeline")


def test_galat_4xx_tidak_diretry(klien, fake_api):
    with pytest.raises(ApiError):
        klien.get_json("/api/v1/tidak-ada")
    assert fake_api.request_count == 1

Fixture klien dan fake_api datang dari tests/conftest.py dan dibahas tuntas pada Bagian 9. Kunci dari tes kedua adalah request_count == 1: server mencatat jumlah permintaan yang diterima, sehingga kita bisa membuktikan klien tidak sekali pun mencoba ulang.

bash
python3 -m pytest tests/test_client.py -q

Output yang Dihasilkan:

text
.....                                                                    [100%]
5 passed in 2.17s

Klien kita kini mampu mengambil satu respons dengan aman, tetapi satu endpoint pesanan berisi 137 baris yang terpotong menjadi tiga halaman. Chapter Ingestion dari API menyebut pembacaan bertahap sebagai keterampilan inti konsumen API; kita menerapkannya pada bagian berikutnya dengan pola iterator. Detail lebih lanjut tentang Session dan timeout tersedia di dokumentasi requests .