Sayfalama ile API Verilerini Toplu Çekme: Pagination Rehberi

Üretim ortamında binlerce, hatta milyonlarca kayıt döndüren bir API ile çalışmak zorunda kaldıysanız, muhtemelen şu sahneyle tanışıklığınız vardır: tek bir istek atarsınız, sunucu birkaç saniye düşünür, sonra ya timeout alırsınız ya da elinize devasa bir JSON yığını geçer ve uygulamanız bunu işlemeye çalışırken bellek çöpe gider. İşte tam bu noktada pagination, yani sayfalama devreye girer. API sayfalama, büyük veri setlerini yönetilebilir parçalara bölerek hem istemci hem de sunucu tarafında kaynak tüketimini dengeler. Bu yazıda pagination’ın ne olduğunu, farklı türlerini, gerçek dünya senaryolarında nasıl kullanacağınızı ve üretim kalitesinde Python/Bash scriptleri ile nasıl otomatize edeceğinizi ele alacağız.

Pagination Neden Gerekli?

Bir veritabanında 500.000 kullanıcı kaydı olan bir sistemden düşünün. İstemci her seferinde tüm kayıtları çekmeye çalışırsa:

  • Sunucu tarafında: Sorgu süresi uzar, bellek tüketimi artar, diğer isteklere servis verme kapasitesi düşer.
  • Ağ tarafında: Büyük response boyutları bant genişliğini tüketir, özellikle mobil istemciler için sorun yaratır.
  • İstemci tarafında: Uygulama belleğe sığmayan veriyi işlemeye çalışırken çöker ya da donup kalır.

Pagination bu sorunu şu şekilde çözer: veriyi sayfalara böler, her sayfa için ayrı bir istek yapılmasına olanak tanır. Kullanıcı veya script hangi sayfada olduğunu takip eder ve veri bitene kadar döngüyle ilerler.

Gerçek bir senaryo düşünelim: Bir e-ticaret platformunun sipariş geçmişini her gece bir veri ambarına aktaran bir ETL pipeline’ı yazıyorsunuz. Günde 50.000 yeni sipariş geldiğini varsayalım. Bunu tek request’te çekmeye çalışmak yerine, 100’erli sayfalarla 500 istek atarak aynı veriyi çok daha güvenli ve kontrollü bir şekilde toplayabilirsiniz.

Pagination Türleri

Offset-Based Pagination (Sayfa Numarası ile)

En yaygın ve anlaşılması en kolay yöntemdir. page ve per_page (veya limit/offset) parametreleriyle çalışır.

  • page: Hangi sayfada olduğunuzu belirtir
  • per_page: Her sayfada kaç kayıt istediğinizi belirtir
  • offset: Kaç kaydı atlayacağınızı belirtir (offset = page * per_page)

Örnek istek yapısı şöyle görünür: GET /api/users?page=3&per_page=100

Bu yöntemin en büyük avantajı kullanım kolaylığıdır. Dezavantajı ise büyük offset değerlerinde veritabanı performansının dramatik biçimde düşmesidir. PostgreSQL’de OFFSET 100000 yazmak, veritabanının o 100.000 kaydı atlayabilmek için önce okuması gerektiği anlamına gelir.

Cursor-Based Pagination (İmleç ile)

Modern API’lerin tercih ettiği yöntemdir. Offset yerine, son görülen kaydın bir “imleçini” (cursor) kullanır. Bu imleç genellikle base64 encode edilmiş bir ID veya timestamp’tir.

  • Büyük veri setlerinde tutarlı performans sağlar
  • Veri ekleme/silinmesinde sayfa kayması olmaz
  • Geriye gitmek zordur, tek yön akış için idealdir

GitHub, Twitter ve Stripe gibi büyük platformlar cursor pagination kullanır.

Keyset Pagination

Cursor pagination’ın bir türevi olan keyset pagination, sıralama alanını (genellikle id veya created_at) doğrudan filtre olarak kullanır: GET /api/orders?after_id=12345&limit=100

Link Header Pagination

RFC 5988 standardına dayanan bu yaklaşımda, sonraki/önceki sayfa URL’leri response’un Link header’ında döner. GitHub API’si bu yöntemi kullanır.

Python ile Offset Pagination

Gelin gerçek bir senaryo üzerinden ilerleyelim. Bir HR sisteminden tüm çalışan kayıtlarını çeken ve CSV’ye yazan bir script yazalım.

pip install requests pandas
#!/usr/bin/env python3
"""
Offset-based pagination ile API verisi çekme
Senaryo: HR sisteminden tüm çalışan verilerini toplu çekme
"""

import requests
import pandas as pd
import time
import logging
from typing import Optional

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

API_BASE_URL = "https://api.example-hr.com/v1"
API_TOKEN = "your-api-token-here"
PAGE_SIZE = 100
MAX_RETRIES = 3
RETRY_DELAY = 2  # saniye

def fetch_page(page: int, per_page: int = PAGE_SIZE) -> Optional[dict]:
    """Belirtilen sayfayı API'den çeker, hata durumunda retry uygular."""
    headers = {
        "Authorization": f"Bearer {API_TOKEN}",
        "Content-Type": "application/json"
    }
    params = {
        "page": page,
        "per_page": per_page,
        "sort": "id",
        "order": "asc"
    }

    for attempt in range(MAX_RETRIES):
        try:
            response = requests.get(
                f"{API_BASE_URL}/employees",
                headers=headers,
                params=params,
                timeout=30
            )
            response.raise_for_status()
            return response.json()

        except requests.exceptions.Timeout:
            logger.warning(f"Sayfa {page}, deneme {attempt + 1}: Timeout, bekleniyor...")
            time.sleep(RETRY_DELAY * (attempt + 1))

        except requests.exceptions.HTTPError as e:
            if response.status_code == 429:
                # Rate limit aşıldı, Retry-After header'ına bak
                retry_after = int(response.headers.get("Retry-After", 60))
                logger.warning(f"Rate limit! {retry_after} saniye bekleniyor...")
                time.sleep(retry_after)
            elif response.status_code >= 500:
                logger.error(f"Sunucu hatası: {e}, yeniden deneniyor...")
                time.sleep(RETRY_DELAY * (attempt + 1))
            else:
                logger.error(f"İstemci hatası: {e}")
                raise

    logger.error(f"Sayfa {page} için maksimum deneme sayısı aşıldı!")
    return None

def fetch_all_employees() -> list:
    """Tüm çalışan kayıtlarını sayfalı olarak çeker."""
    all_employees = []
    page = 1
    total_pages = None

    logger.info("Veri çekme başlıyor...")

    while True:
        logger.info(f"Sayfa çekiliyor: {page}" +
                   (f"/{total_pages}" if total_pages else ""))

        data = fetch_page(page)

        if not data:
            logger.error(f"Sayfa {page} alınamadı, durduruluyor.")
            break

        # Toplam sayfa sayısını ilk response'dan al
        if total_pages is None:
            total_records = data.get("meta", {}).get("total", 0)
            total_pages = (total_records + PAGE_SIZE - 1) // PAGE_SIZE
            logger.info(f"Toplam kayıt: {total_records}, Toplam sayfa: {total_pages}")

        employees = data.get("data", [])

        if not employees:
            logger.info("Boş sayfa alındı, tüm veri çekildi.")
            break

        all_employees.extend(employees)
        logger.info(f"Sayfa {page} çekildi, {len(employees)} kayıt eklendi. "
                   f"Toplam: {len(all_employees)}")

        # Son sayfaya ulaşıldı mı?
        if total_pages and page >= total_pages:
            break

        page += 1
        # Sunucuya nazik olmak için kısa bir bekleme
        time.sleep(0.1)

    return all_employees

if __name__ == "__main__":
    employees = fetch_all_employees()

    if employees:
        df = pd.DataFrame(employees)
        output_file = "employees_export.csv"
        df.to_csv(output_file, index=False, encoding="utf-8-sig")
        logger.info(f"Toplam {len(employees)} kayıt '{output_file}' dosyasına yazıldı.")
    else:
        logger.error("Hiçbir veri çekilemedi!")

Bu scriptte dikkat etmemiz gereken birkaç kritik nokta var: exponential backoff ile retry mekanizması, rate limit tespiti ve toplam sayfa hesaplaması.

Cursor-Based Pagination: GitHub API Örneği

GitHub’ın API’si her iki yöntemi de destekler ama cursor (Link header) pagination’ı daha verimlidir. Bir organizasyonun tüm repository’lerini çeken gerçek bir örnek:

#!/usr/bin/env python3
"""
Link Header (Cursor) pagination ile GitHub API kullanımı
Senaryo: Bir organizasyonun tüm repo istatistiklerini çekme
"""

import requests
import json
import re
from typing import Optional

GITHUB_TOKEN = "ghp_your_token_here"
ORG_NAME = "your-organization"

def parse_link_header(link_header: str) -> dict:
    """GitHub Link header'ını parse eder ve next/prev URL'lerini döner."""
    links = {}
    if not link_header:
        return links

    for part in link_header.split(","):
        section = part.strip().split(";")
        if len(section) == 2:
            url = section[0].strip()[1:-1]  # < ve > karakterlerini kaldır
            rel = re.findall(r'rel="([^"]+)"', section[1])[0]
            links[rel] = url

    return links

def fetch_org_repos(org_name: str) -> list:
    """Organizasyonun tüm repository'lerini cursor pagination ile çeker."""
    headers = {
        "Authorization": f"token {GITHUB_TOKEN}",
        "Accept": "application/vnd.github.v3+json"
    }

    url = f"https://api.github.com/orgs/{org_name}/repos"
    params = {
        "per_page": 100,
        "type": "all",
        "sort": "updated"
    }

    all_repos = []
    page_num = 0

    while url:
        page_num += 1
        print(f"Sayfa {page_num} çekiliyor: {url}")

        response = requests.get(url, headers=headers, params=params)
        response.raise_for_status()

        # Kalan rate limit bilgisini logla
        remaining = response.headers.get("X-RateLimit-Remaining", "?")
        reset_time = response.headers.get("X-RateLimit-Reset", "?")
        print(f"Rate Limit Kalan: {remaining}")

        repos = response.json()
        all_repos.extend(repos)
        print(f"Bu sayfada {len(repos)} repo, toplam: {len(all_repos)}")

        # Link header'ından sonraki sayfayı al
        link_header = response.headers.get("Link", "")
        links = parse_link_header(link_header)

        # Bir sonraki sayfanın URL'si (cursor)
        url = links.get("next")

        # İlk sayfadan sonra params'ı temizle (URL zaten parametreleri içeriyor)
        params = {}

    return all_repos

repos = fetch_org_repos(ORG_NAME)
print(f"nToplam {len(repos)} repository bulundu.")

# En yüksek star'lı 10 repo'yu göster
sorted_repos = sorted(repos, key=lambda x: x.get("stargazers_count", 0), reverse=True)
for repo in sorted_repos[:10]:
    print(f"  {repo['name']}: {repo['stargazers_count']} star")

Bash ile Pagination: Hızlı ve Sade

Her zaman Python’a ihtiyaç yoktur. Basit bir veri dökümü için Bash yeterlidir:

#!/bin/bash
# Offset pagination ile API'den veri çeken Bash scripti
# Senaryo: Monitoring sisteminden alert geçmişini çekme

API_URL="https://monitoring.example.com/api/v2"
API_KEY="your-api-key"
OUTPUT_FILE="alerts_export.jsonl"
PAGE_SIZE=200
CURRENT_PAGE=1
TOTAL_FETCHED=0

# Çıktı dosyasını temizle
> "$OUTPUT_FILE"

echo "Alert geçmişi çekme başlıyor..."

while true; do
    echo "Sayfa $CURRENT_PAGE çekiliyor..."

    # API isteği at
    RESPONSE=$(curl -s -w "n%{http_code}" 
        -H "Authorization: Bearer $API_KEY" 
        -H "Content-Type: application/json" 
        "${API_URL}/alerts?page=${CURRENT_PAGE}&per_page=${PAGE_SIZE}&status=resolved")

    # HTTP status code'u ayır
    HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
    BODY=$(echo "$RESPONSE" | head -n -1)

    # Hata kontrolü
    if [ "$HTTP_CODE" != "200" ]; then
        echo "HATA: HTTP $HTTP_CODE alındı, durduruluyor."
        echo "Response: $BODY"
        exit 1
    fi

    # Kayıt sayısını al (jq gerekli)
    RECORD_COUNT=$(echo "$BODY" | jq -r '.data | length')
    TOTAL_PAGES=$(echo "$BODY" | jq -r '.meta.total_pages // "unknown"')

    echo "Sayfa $CURRENT_PAGE/$TOTAL_PAGES: $RECORD_COUNT kayıt"

    # Boş sayfa kontrolü
    if [ "$RECORD_COUNT" -eq 0 ] 2>/dev/null; then
        echo "Boş sayfa alındı. Tüm veri çekildi."
        break
    fi

    # Her kaydı ayrı JSON satırı olarak yaz (JSONL format)
    echo "$BODY" | jq -c '.data[]' >> "$OUTPUT_FILE"

    TOTAL_FETCHED=$((TOTAL_FETCHED + RECORD_COUNT))
    echo "Toplam çekilen: $TOTAL_FETCHED kayıt"

    # Son sayfaya ulaşıldı mı?
    HAS_NEXT=$(echo "$BODY" | jq -r '.meta.has_next_page // false')
    if [ "$HAS_NEXT" = "false" ]; then
        echo "Son sayfaya ulaşıldı."
        break
    fi

    CURRENT_PAGE=$((CURRENT_PAGE + 1))

    # Sunucuya biraz nefes ver
    sleep 0.2
done

echo "Tamamlandı! Toplam $TOTAL_FETCHED kayıt '$OUTPUT_FILE' dosyasına yazıldı."
echo "Dosya boyutu: $(wc -l < "$OUTPUT_FILE") satır"

Rate Limiting ile Profesyonel Yaklaşım

Üretim ortamında en sık karşılaşılan sorun rate limit’tir. Özellikle büyük veri setlerini toplu çekerken API sizi kısıtlamaya başlar. Bunu akıllıca yönetmek gerekir:

#!/usr/bin/env python3
"""
Akıllı rate limit yönetimi ile pagination
Senaryo: CRM'den müşteri verilerini çekme
"""

import requests
import time
import logging
from dataclasses import dataclass
from typing import Generator, Optional

logger = logging.getLogger(__name__)

@dataclass
class RateLimitConfig:
    """Rate limit konfigürasyon sınıfı."""
    requests_per_minute: int = 60
    burst_size: int = 10
    backoff_factor: float = 1.5
    max_backoff: int = 300  # maksimum bekleme süresi (saniye)

class PaginatedAPIClient:
    """Rate limit farkındalıklı sayfalama istemcisi."""

    def __init__(self, base_url: str, api_key: str,
                 rate_config: Optional[RateLimitConfig] = None):
        self.base_url = base_url
        self.session = requests.Session()
        self.session.headers.update({
            "Authorization": f"Bearer {api_key}",
            "User-Agent": "DataExporter/1.0"
        })
        self.rate_config = rate_config or RateLimitConfig()
        self.request_count = 0
        self.start_time = time.time()

    def _throttle(self):
        """İstek hızını kontrol eder, gerekirse bekler."""
        self.request_count += 1
        elapsed = time.time() - self.start_time

        # Dakika başına istek oranını hesapla
        if elapsed > 0:
            current_rate = (self.request_count / elapsed) * 60
            max_rate = self.rate_config.requests_per_minute

            if current_rate > max_rate:
                wait_time = (self.request_count / (max_rate / 60)) - elapsed
                if wait_time > 0:
                    logger.debug(f"Throttle: {wait_time:.2f}s bekleniyor "
                               f"(mevcut oran: {current_rate:.1f}/dk)")
                    time.sleep(wait_time)

    def _handle_rate_limit_response(self, response: requests.Response) -> float:
        """429 response'undan bekleme süresini hesaplar."""
        # Retry-After header'ı varsa onu kullan
        retry_after = response.headers.get("Retry-After")
        if retry_after:
            return float(retry_after)

        # X-RateLimit-Reset header'ı varsa onu kullan
        reset_time = response.headers.get("X-RateLimit-Reset")
        if reset_time:
            wait = float(reset_time) - time.time()
            return max(wait, 1)

        return 60  # Varsayılan: 1 dakika bekle

    def paginate(self, endpoint: str, params: dict = None) -> Generator:
        """Endpoint'i sayfalı olarak gezer, kayıtları generator ile döner."""
        params = params or {}
        params.setdefault("per_page", 100)
        page = 1
        backoff = 1

        while True:
            params["page"] = page
            self._throttle()

            try:
                response = self.session.get(
                    f"{self.base_url}{endpoint}",
                    params=params,
                    timeout=30
                )

                if response.status_code == 429:
                    wait_time = self._handle_rate_limit_response(response)
                    logger.warning(f"Rate limit! {wait_time:.0f}s bekleniyor...")
                    time.sleep(wait_time)
                    continue  # Aynı sayfayı tekrar dene

                response.raise_for_status()
                backoff = 1  # Başarılı istek, backoff'u sıfırla

                data = response.json()
                records = data.get("data", data if isinstance(data, list) else [])

                if not records:
                    logger.info(f"Sayfalama tamamlandı. Toplam {page-1} sayfa işlendi.")
                    break

                logger.info(f"Sayfa {page}: {len(records)} kayıt döndü")
                yield from records

                # Sonraki sayfa var mı?
                meta = data.get("meta", {})
                if not meta.get("has_next_page", len(records) == params["per_page"]):
                    break

                page += 1

            except requests.exceptions.RequestException as e:
                logger.error(f"İstek hatası (sayfa {page}): {e}")
                wait_time = min(backoff * self.rate_config.backoff_factor,
                              self.rate_config.max_backoff)
                logger.info(f"{wait_time:.0f}s sonra yeniden deneniyor...")
                time.sleep(wait_time)
                backoff = wait_time


# Kullanım örneği
client = PaginatedAPIClient(
    base_url="https://api.crm.example.com/v1",
    api_key="your-crm-api-key",
    rate_config=RateLimitConfig(requests_per_minute=30)
)

all_customers = []
for customer in client.paginate("/customers", params={"country": "TR", "active": True}):
    all_customers.append(customer)
    if len(all_customers) % 1000 == 0:
        print(f"{len(all_customers)} müşteri işlendi...")

print(f"Toplam {len(all_customers)} müşteri çekildi.")

Paralel Pagination: İleri Seviye Hız Optimizasyonu

Sayfa sayısını önceden bildiğiniz durumlarda, sayfaları paralel olarak çekebilirsiniz. Bu, toplam çekme süresini önemli ölçüde kısaltır. Ancak rate limit’e dikkat etmek gerekir:

#!/usr/bin/env python3
"""
ThreadPoolExecutor ile paralel pagination
DİKKAT: Sadece API rate limit'i uygunsa kullanın!
Senaryo: Büyük ürün kataloğunu hızlıca indirme
"""

import requests
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock

API_URL = "https://api.catalog.example.com/v1"
API_TOKEN = "your-token"
MAX_WORKERS = 5  # Aynı anda maksimum 5 paralel istek
PAGE_SIZE = 200

results_lock = Lock()
all_products = []

def get_total_pages() -> int:
    """İlk isteği atarak toplam sayfa sayısını öğren."""
    response = requests.get(
        f"{API_URL}/products",
        headers={"Authorization": f"Bearer {API_TOKEN}"},
        params={"page": 1, "per_page": PAGE_SIZE}
    )
    response.raise_for_status()
    data = response.json()
    total = data["meta"]["total_records"]
    return (total + PAGE_SIZE - 1) // PAGE_SIZE

def fetch_page_parallel(page_num: int) -> tuple:
    """Tek bir sayfayı çeker, (page_num, records) tuple'ı döner."""
    try:
        response = requests.get(
            f"{API_URL}/products",
            headers={"Authorization": f"Bearer {API_TOKEN}"},
            params={"page": page_num, "per_page": PAGE_SIZE},
            timeout=30
        )
        response.raise_for_status()
        data = response.json()
        return (page_num, data.get("data", []))
    except Exception as e:
        print(f"Sayfa {page_num} hatası: {e}")
        return (page_num, [])

def fetch_all_parallel():
    """Tüm sayfaları paralel olarak çeker."""
    print("Toplam sayfa sayısı alınıyor...")
    total_pages = get_total_pages()
    print(f"Toplam {total_pages} sayfa paralel çekilecek ({MAX_WORKERS} worker ile)")

    ordered_results = {}
    completed = 0

    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
        future_to_page = {
            executor.submit(fetch_page_parallel, page): page
            for page in range(1, total_pages + 1)
        }

        for future in as_completed(future_to_page):
            page_num, records = future.result()
            ordered_results[page_num] = records
            completed += 1

            if completed % 10 == 0:
                print(f"İlerleme: {completed}/{total_pages} sayfa tamamlandı")

            # Worker'lar arası küçük bir bekleme (rate limit koruması)
            time.sleep(0.05)

    # Sayfaları sıralı birleştir
    final_results = []
    for page_num in sorted(ordered_results.keys()):
        final_results.extend(ordered_results[page_num])

    return final_results

products = fetch_all_parallel()
print(f"Toplam {len(products)} ürün çekildi.")

Gerçek Dünya Sorunları ve Çözümleri

Sayfalama ile çalışırken production’da sık karşılaşılan birkaç tuzaktan bahsedelim.

Veri kayması problemi: Offset-based pagination kullanırken, siz sayfalarda gezinirken yeni kayıtlar eklenirse veya silinirse, bazı kayıtları atlayabilir ya da iki kez görebilirsiniz. Çözüm, çekme başlamadan önce bir snapshot_time belirlemek ve tüm isteklerde bu filtreyi kullanmaktır: ?created_before=2024-01-15T10:00:00Z&page=X

Büyük offset sorunları: Bir API’nin 500. sayfasını çekmeye çalıştığınızda yanıt süresinin aniden uzadığını fark edebilirsiniz. Bu, veritabanının OFFSET 50000 yapması gerektiği anlamına gelir. Eğer API cursor pagination desteklemiyorsa ve bunu kontrol edemiyorsanız, veriyi daha küçük zaman dilimlerine bölerek çekin.

Tutarsız total_count: Bazı API’ler her sayfada farklı total değeri döndürebilir (çok nadir ama yaşanıyor). Scripti her zaman hem sayfa numarasına hem de “boş sayfa” kontrolüne güvenecek şekilde yazın, sadece total_pages‘e güvenmeyin.

Yarım kalan işlemler: Büyük bir çekim işlemi yarıda kesilirse baştan başlamak istemezsiniz. Checkpoint mekanizması ekleyin: her 1000 kayıtta bir ilerlemeyi bir dosyaya kaydedin ve script yeniden başladığında kaldığı yerden devam etsin.

Sonuç

Pagination, API entegrasyonlarının bel kemiğidir ve doğru uygulanmadığında hem production sistemlerinizi hem de entegre olduğunuz API’yi gereksiz yere yorar. Temel prensipleri bir daha sıralayalım:

  • Offset pagination ile basit ve küçük veri setlerinde kullanımı kolaydır, büyük veri setlerinde performans sorunu yaratır.
  • Cursor pagination büyük ve sürekli büyüyen veri setleri için doğru tercihtir.
  • Her zaman retry mekanizması ve rate limit yönetimi ekleyin, bunlar isteğe bağlı değil zorunludur.
  • Paralel pagination hız kazandırır ama rate limit’e saygı göstererek yapılmalıdır.
  • Uzun çekimler için checkpoint mekanizması, yarıda kalan işlerin baştan başlamasını önler.

Production’da bu teknikleri uygularken her zaman kendinize şu soruyu sorun: “Bu script gece 3’te başarısız olursa ve ben uyuyorsam, sabah uyandığımda ne kadar veri kaybedilmiş olur?” Cevap “hiçbiri” ise doğru yazmışsınız demektir.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir