Dataset 137 pesanan dengan per_page 50 berarti satu endpoint dipecah menjadi tiga respons terpisah. Mengambil hanya halaman pertama membuat pipeline kehilangan 87 pesanan tanpa galat apa pun, jadi kesalahan pagination termasuk yang paling berbahaya karena senyap. Pada bagian ini kita membangun penjelajah halaman sebagai generator di src/pagination.py, terpisah dari klien HTTP agar mudah diuji.

Pola Iterator #

Fungsi paginate menerima fungsi fetch_page, bukan objek klien. Pemisahan membuat fungsi ini bisa diuji dengan fetch palsu tanpa server HTTP sama sekali:

python
def paginate(fetch_page, max_pages=1000):
    """Yield daftar baris per halaman sampai halaman terakhir.

    ``fetch_page(nomor)`` harus mengembalikan payload berbentuk
    ``{"data": [...], "meta": {...}}``. Iterasi berhenti ketika halaman kosong,
    ketika ``meta.total_pages`` tercapai, atau ketika ``max_pages`` terlampaui
    sebagai pagar pengaman kontrak API yang tidak konsisten.
    """
    halaman = 1
    while halaman <= max_pages:
        payload = fetch_page(halaman)
        baris = payload.get("data") or []
        meta = payload.get("meta") or {}
        total_halaman = meta.get("total_pages")
        logger.info("halaman %s/%s: %d baris", halaman, total_halaman or "?",
                    len(baris))
        yield baris
        if not baris:
            return
        if total_halaman is not None and halaman >= total_halaman:
            return
        halaman += 1
    raise PaginationError(
        f"pagination melebihi {max_pages} halaman; nilai meta.total_pages "
        "dari API patut diperiksa"
    )

Mari kita telaah baris-baris kode di atas:

  1. Baris 21-22 (while halaman <= max_pages): Nomor halaman berjalan dari 1; kondisi while menjadi pagar luar dari penjelajahan.
  2. Baris 23-26 (payload, baris, meta): Respons diurai secara defensif dengan or [] dan or {}, sehingga envelope yang cacat tidak langsung memunculkan TypeError.
  3. Baris 27-28 (logger.info): Setiap halaman dicatat dengan nomor halaman dan jumlah barisnya; jejak ini tampak pada log run pipeline di Bagian 7.
  4. Baris 29 (yield baris): Sebagai generator, paginate menyerahkan satu halaman per iterasi sehingga pemanggil bisa memproses data sejak halaman pertama tiba.
  5. Baris 30-31 (if not baris: return): Halaman kosong adalah sinyal alami akhir data, terutama ketika meta tidak menyertakan total_pages.
  6. Baris 32-33 (halaman >= total_halaman): Bila total_pages tersedia, penjelajahan berhenti tepat di halaman terakhir tanpa satu permintaan pun yang sia-sia.
  7. Baris 35-38 (raise PaginationError): Bila keluar dari loop karena max_pages terlampaui, penjelajahan dihentikan dengan galat eksplisit, bukan diam-diam berhenti.

Menjelajahi Seluruh Halaman #

Pipeline memakai paginate dengan menggabungkan nomor halaman ke parameter klien. Skrip berikut menghitung seluruh pesanan yang bisa diambil:

bash
python3 - <<'PY'
import logging

from src.client import ApiClient
from src.pagination import paginate

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

klien = ApiClient("http://127.0.0.1:8765", timeout=5.0)


def fetch(nomor):
    return klien.get_json("/api/v1/pesanan", {"page": nomor, "per_page": 50})


total = 0
for baris in paginate(fetch):
    total += len(baris)
print("total pesanan:", total)
PY

Output yang Dihasilkan:

text
halaman 1/3: 50 baris
halaman 2/3: 50 baris
halaman 3/3: 37 baris
total pesanan: 137

Tiga baris log menunjukkan penjelajahan berhenti tepat setelah halaman ketiga, dan total 50 + 50 + 37 = 137 cocok dengan total_records pada meta. Ketika kegagalan jaringan terjadi di tengah penjelajahan, klien dari Bagian 2 yang menangani percobaan ulang per halaman, sehingga paginate tetap sederhana.

Pagar Pengaman max_pages #

max_pages melindungi pipeline dari dua skenario buruk: API yang selalu mengklaim ada halaman berikutnya, dan kesalahan logika yang membuat nomor halaman berputar tanpa henti. Tanpa pagar, loop while bisa berjalan selamanya dan menagih kuota API setiap detiknya. Nilai bawaan 1000 halaman diatur di config/pipeline.yaml pada bagian paging.max_pages dan bisa disesuaikan dengan ukuran dataset.

Uji Pagination #

Karena paginate tidak bergantung pada HTTP, tes di tests/test_pagination.py memakai fetch palsu berbasis dict. Dua kasus berikut menguji pagar pengaman dan kondisi meta yang tidak lengkap:

python
def test_total_hilang_lanjut_sampai_halaman_kosong():
    isi = {1: ["a"], 2: ["b"], 3: []}
    def fetch(nomor):
        return {"data": isi[nomor], "meta": {}}
    assert [baris for baris in paginate(fetch)] == [["a"], ["b"], []]


def test_max_pages_memicu_pagar_pengaman():
    def fetch(nomor):
        return {"data": [f"baris-{nomor}"], "meta": {"total_pages": 10_000}}
    with pytest.raises(PaginationError):
        list(paginate(fetch, max_pages=5))

Tes pertama membuktikan penjelajahan tetap maju walaupun total_pages absen, dengan berhenti pada halaman kosong. Tes kedua memaksa API yang mengklaim 10.000 halaman dan memastikan pagar max_pages=5 menghentikan penjelajahan dengan galat.

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

Output yang Dihasilkan:

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

Penjelajah halaman kita kini utuh dan teruji, mengikuti pola yang dibahas pada chapter Menangani Pagination API. Namun penjelajahan tiga halaman berarti tiga permintaan beruntun, dan API publik membatasi seberapa rapat permintaan boleh datang. Bagian berikutnya mengajari klien menghormati batas tersebut lewat header Retry-After. Detail tentang perilaku generator dan iterator Python tersedia di dokumentasi tipe bawaan Python .