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:
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:
- Baris 26 (
base_url.rstrip("/")): Garis miring di akhir alamat dibuang agar penyambunganbase_url + pathselalu menghasilkan URL yang benar. - Baris 27 (
self.timeout = timeout): Nilai timeout disimpan dan dipakai pada setiap permintaan; tidak ada satu pun panggilangettanpa batas waktu. - Baris 28 (
policy or RetryPolicy()): Kebijakan retry bisa disuntik dari luar untuk keperluan pengujian, dengan nilai bawaan yang masuk akal. - Baris 29 (
requests.Session()):Sessionmenyimpan koneksi TCP sehingga permintaan kedua dan seterusnya tidak perlu melakukan handshake ulang; session juga menampung header tetap. - Baris 30-33 (
headers.update): HeaderAcceptdanUser-Agentdikirim 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:
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"])
PYOutput yang Dihasilkan:
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:
| Kondisi | Sifat galat | Tindakan klien |
|---|---|---|
200 | Berhasil | Kembalikan payload JSON |
429 | Sementara (kuota) | Tunggu sesuai Retry-After, lalu ulangi |
5xx | Sementara (server) | Exponential backoff, lalu ulangi |
| Timeout / koneksi gagal | Sementara (jaringan) | Exponential backoff, lalu ulangi |
4xx lainnya | Permanen | Angkat ApiError tanpa percobaan ulang |
Logika tersebut hidup di get_json, satu-satunya pintu keluar permintaan:
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:
- Baris 38 (
for attempt in range(...)): Permintaan pertama ditambahmax_retriespercobaan ulang membentuk anggaran percobaan; setiap iterasi adalah satu kesempatan. - Baris 40-42 (
except requests.RequestException): Gangguan jaringan seperti koneksi ditolak atau timeout ditangkap sebagai galat sementara, bukan langsung menggagalkan run. - Baris 45-46 (
status_code == 200): Hanya status 200 dianggap berhasil; payload langsung dikembalikan sebagai dict. - Baris 47-51 (
429 atau >= 500): Kedua kelompok status ini layak diulang; headerRetry-Afterdiurai lebih dulu jika ada. - Baris 52-54 (
raise ApiError): Status4xxlain 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:
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 == 1Fixture 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.
python3 -m pytest tests/test_client.py -qOutput yang Dihasilkan:
..... [100%]
5 passed in 2.17sKlien 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
.