API kurir, gateway pembayaran, dan marketplace tidak pernah mengirim seluruh data sekaligus. Endpoint riwayat pesanan bisa menyimpan ratusan ribu baris, sehingga penyedia API membatasinya menjadi halaman-halaman kecil: sekali permintaan hanya mengembalikan, misalnya, 3 sampai 100 baris. Pipeline ingestion yang kita bangun pada Chapter Ingestion dari API harus mampu menarik semua halaman sampai habis tanpa ada data yang terlewat dan tanpa berputar selamanya. Chapter ini membahas dua strategi pagination yang dominan, mempraktikkan keduanya lewat API tiruan lokal, lalu membedah jebakan yang paling sering menjatuhkan pipeline di produksi.

Dua Strategi Pagination #

Pagination page-based menomori setiap halaman. Klien meminta page=1, lalu page=2, dan seterusnya; server menghitung posisi awal data dengan rumus (page - 1) * limit. Pagination cursor-based tidak memakai nomor halaman, melainkan token posisi: setiap respons memuat next_cursor yang menunjuk data setelah baris terakhir yang baru dikirim, dan klien memakai token tersebut pada permintaan berikutnya. Sederhananya, page-based menjawab pertanyaan “halaman berapa” sedangkan cursor-based menjawab “lanjut dari mana” — perbedaan kecil yang berdampak besar saat data berubah di tengah proses.

AspekPage-BasedCursor-Based
Parameter permintaanpage, limitcursor, limit
Lompat ke halaman tertentubisatidak bisa, harus berurutan
Stabil saat data berubahtidak, isi halaman bisa bergeserya, posisi terkunci pada data terakhir
Contoh umumAPI internal sederhanaAPI skala besar seperti Stripe

Contoh: API Tiruan Lokal dan Klien Paginate #

Kita akan meniru API riwayat pesanan toko online dengan http.server yang berjalan di thread terpisah, sehingga tidak perlu koneksi internet. Server menyediakan dua endpoint: /pesanan untuk gaya page-based dan /pesanan-cursor untuk gaya cursor-based, sama-sama membatasi tiga pesanan per respons.

python
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

import requests

PESANAN = [
    {"id": 1, "pelanggan": "Budi", "total": 150000},
    {"id": 2, "pelanggan": "Siti", "total": 275000},
    {"id": 3, "pelanggan": "Dewi", "total": 99000},
    {"id": 4, "pelanggan": "Budi", "total": 430000},
    {"id": 5, "pelanggan": "Siti", "total": 320000},
    {"id": 6, "pelanggan": "Dewi", "total": 87000},
    {"id": 7, "pelanggan": "Budi", "total": 199000},
    {"id": 8, "pelanggan": "Siti", "total": 560000},
]


class HandlerAPI(BaseHTTPRequestHandler):
    def do_GET(self):
        url = urlparse(self.path)
        params = parse_qs(url.query)
        if url.path == "/pesanan":  # endpoint page-based
            halaman = int(params.get("page", ["1"])[0])
            limit = int(params.get("limit", ["3"])[0])
            mulai = (halaman - 1) * limit
            body = {"data": PESANAN[mulai:mulai + limit]}
        else:  # endpoint cursor-based
            limit = int(params.get("limit", ["3"])[0])
            kursor = int(params.get("cursor", ["0"])[0])
            data = [p for p in PESANAN if p["id"] > kursor][:limit]
            body = {"data": data, "next_cursor": data[-1]["id"] if data else None}
        payload = json.dumps(body).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def log_message(self, *args):
        pass  # matikan log bawaan http.server


server = ThreadingHTTPServer(("127.0.0.1", 0), HandlerAPI)
threading.Thread(target=server.serve_forever, daemon=True).start()
base_url = f"http://127.0.0.1:{server.server_address[1]}"

Server di atas sengaja tidak mengirim jumlah total halaman. Karena klien memang tidak boleh mengandalkan informasi tersebut, strategi pengulangannya justru menjadi lebih sederhana: terus meminta halaman berikutnya sampai respons kosong. Berikut klien untuk kedua strategi tersebut; klien pertama menaikkan nomor halaman satu per satu, sedangkan klien kedua mengikuti rantai cursor sampai token habis.

python
# --- strategi 1: pagination berbasis halaman ---
hasil_halaman = []
halaman = 1
while True:
    jawab = requests.get(f"{base_url}/pesanan", params={"page": halaman, "limit": 3})
    jawab.raise_for_status()
    isi = jawab.json()
    if not isi["data"]:
        print(f"halaman {halaman}: kosong -> berhenti")
        break
    id_pesanan = [p["id"] for p in isi["data"]]
    print(f"halaman {halaman}: {len(isi['data'])} pesanan (id: {id_pesanan})")
    hasil_halaman.extend(isi["data"])
    halaman += 1
print(f"total pagination halaman: {len(hasil_halaman)} pesanan")

# --- strategi 2: pagination berbasis cursor ---
hasil_cursor = []
kursor = "0"
while kursor is not None:
    jawab = requests.get(f"{base_url}/pesanan-cursor",
                         params={"cursor": kursor, "limit": 3})
    jawab.raise_for_status()
    isi = jawab.json()
    if isi["data"]:
        id_pesanan = [p["id"] for p in isi["data"]]
        print(f"cursor {kursor}: {len(isi['data'])} pesanan (id: {id_pesanan})")
    hasil_cursor.extend(isi["data"])
    kursor = isi["next_cursor"]
print(f"total pagination cursor: {len(hasil_cursor)} pesanan")

server.shutdown()

Output yang Dihasilkan:

text
halaman 1: 3 pesanan (id: [1, 2, 3])
halaman 2: 3 pesanan (id: [4, 5, 6])
halaman 3: 2 pesanan (id: [7, 8])
halaman 4: kosong -> berhenti
total pagination halaman: 8 pesanan
cursor 0: 3 pesanan (id: [1, 2, 3])
cursor 3: 3 pesanan (id: [4, 5, 6])
cursor 6: 2 pesanan (id: [7, 8])
total pagination cursor: 8 pesanan

Mari kita telaah baris-baris kode klien di atas:

  1. Baris 4 (while True:): klien page-based berputar tanpa batas halaman karena jumlah halaman tidak diketahui; loop hanya berhenti lewat break.
  2. Baris 8-10 (if not isi["data"]): kondisi berhenti — respons kosong berarti tidak ada lagi data, jadi loop berakhir. Inilah pola “loop sampai halaman kosong”.
  3. Baris 14 (halaman += 1): nomor halaman maju satu langkah setiap iterasi; lupa menaikkan nilai variabel ini membuat klien meminta halaman yang sama tanpa henti.
  4. Baris 20 (while kursor is not None:): klien cursor-based berhenti ketika next_cursor bernilai None, tanda dari server bahwa data sudah habis — tanpa perlu permintaan tambahan untuk halaman kosong.
  5. Baris 29 (kursor = isi["next_cursor"]): token dari respons dipakai sebagai parameter permintaan berikutnya, membentuk rantai permintaan yang berurutan.

Jebakan Pagination di Dunia Nyata #

Tiga jebakan paling sering muncul saat menarik data berhalaman. Pertama, data berubah di tengah proses: dengan page-based, satu pesanan baru yang masuk sebelum halaman 2 diminta membuat semua baris bergeser, sehingga satu pesanan terlewat dan satu pesanan tampil dua kali; cursor-based kebal terhadap masalah ini karena posisi terkunci pada id terakhir yang diterima. Kedua, batas limit maksimum: banyak API menutupi nilai di atas 100 atau 1000 secara diam-diam, jadi klien yang meminta limit=5000 tetap menerima 100 baris tanpa peringatan. Ketiga, penanda akhir yang tidak konsisten antar API: sebagian memakai next_cursor, sebagian memakai has_more, sebagian cukup respons kosong — selalu baca dokumentasi penyedianya. Uji juga dengan jumlah data yang tidak habis dibagi limit, misalnya delapan pesanan dengan limit tiga, karena sisa di halaman terakhir sering menjadi sumber bug off-by-one. Detail pemakaian requests.get() dan raise_for_status() bisa dicek di https://requests.readthedocs.io/ .

Menarik ribuan halaman berarti mengirim ribuan permintaan dalam waktu singkat — dan di titik itulah API mulai menolak kita dengan status 429. Cara menghormati rate limit sambil tetap menyelesaikan pekerjaan dibahas pada Chapter Rate Limiting dan Retries.