Learning Platform
Урок 08.03 · 20 мин
Начальный
PaginationCursorLink headerREST APIGenerators
Пагинация: offset, cursor, keyset, Link header и GraphQL Когда linked list оправдан: cursor-based pagination как linked list

Почему API не отдаёт всё сразу

В прошлом уроке мы сделали gh.list_issues("python", "cpython"). Если у проекта 10 issues — всё работает. Если у проекта 50000 — наша функция вернёт первые 100 (per_page=100) и тихо потеряет остальные 49900.

Это не баг библиотеки. Это сознательное решение серверной стороны: ни один разумный API не отдаст 50000 объектов одним JSON-ом. Это:

  • Гигабайты трафика → таймауты у клиента и нагрузка на сервер.
  • Долгий запрос держит коннект и БД-курсор — соседи API получают 503.
  • Клиент не сможет показать прогресс, обработать данные потоком, продолжить после ошибки.

Поэтому каждый production API ограничивает страницу (обычно 50-500 элементов) и даёт способ запросить следующую. Этот способ называется

pagination
.

Существуют четыре основных паттерна. Junior должен уметь распознать каждый по виду URL/response и обернуть в Python-генератор, чтобы пользователь писал просто:

for issue in gh.list_all_issues("python", "cpython"):
    process(issue)

И не знал, что под капотом сделано 500 HTTP-запросов.

Паттерн 1: offset / limit

GET /items?offset=0&limit=50      → первые 50
GET /items?offset=50&limit=50     → следующие 50
GET /items?offset=100&limit=50    → следующие 50
...

Самый простой паттерн. Параметры — позиция начала (offset) и размер страницы (limit). В ответе обычно либо массив объектов, либо объект вроде {"items": [...], "total": 50000}.

Generator-обёртка:

from collections.abc import Iterator
from typing import Any
import httpx


def paginate_offset(
    client: httpx.Client,
    url: str,
    *,
    page_size: int = 100,
    items_key: str = "items",
) -> Iterator[dict[str, Any]]:
    """Тянет все страницы offset/limit-API и выдаёт элементы по одному."""
    offset = 0
    while True:
        response = client.get(url, params={"offset": offset, "limit": page_size})
        response.raise_for_status()
        page = response.json()
        items = page[items_key] if items_key else page
        if not items:
            break
        yield from items
        if len(items) < page_size:
            break    # последняя страница не полная — конец
        offset += page_size

Условие выхода — либо пустой массив, либо страница короче, чем page_size. Оба варианта надёжны, оба нужны, потому что разные API ведут себя по-разному.

Проблемы offset:

  • При больших offset запрос медленный. SQL под капотом делает LIMIT 50 OFFSET 100000 — БД должна просканировать 100000 строк, чтобы пропустить их.
  • Если данные меняются во время пагинации (кто-то вставил/удалил элементы) — вы можете увидеть дубликаты или пропуски. Offset считает «по позиции», а позиции сдвигаются.

Поэтому offset подходит для маленьких коллекций (до нескольких тысяч объектов) и статичных данных. На больших и горячих — нужны другие паттерны.

Паттерн 2: page-based

GET /items?page=1&per_page=50
GET /items?page=2&per_page=50
GET /items?page=3&per_page=50
...

Косметическая разница с offset: вместо абсолютной позиции — номер страницы. page=1 = offset=0, page=2 = offset=50 и т.д.

Generator:

def paginate_pages(
    client: httpx.Client,
    url: str,
    *,
    page_size: int = 100,
) -> Iterator[dict[str, Any]]:
    page = 1
    while True:
        response = client.get(url, params={"page": page, "per_page": page_size})
        response.raise_for_status()
        items = response.json()
        if not items:
            break
        yield from items
        if len(items) < page_size:
            break
        page += 1

Все те же проблемы, что у offset — те же ограничения. В GitHub API, например, page-based используется в дополнение к Link header (про него ниже).

Паттерн 3: cursor-based

GET /items?limit=50
  → response: {"items": [...], "next_cursor": "eyJpZCI6MTAwfQ=="}
GET /items?cursor=eyJpZCI6MTAwfQ==&limit=50
  → response: {"items": [...], "next_cursor": "eyJpZCI6MjAwfQ=="}
GET /items?cursor=eyJpZCI6MjAwfQ==&limit=50
  → response: {"items": [...], "next_cursor": null}

Cursor
— это непрозрачный токен (обычно base64 от ID или timestamp последнего элемента), который сервер возвращает вместе со страницей. Клиент не парсит cursor — просто передаёт обратно в следующем запросе.

Преимущества огромные:

  • Сервер делает WHERE id > <cursor_id> LIMIT 50 — это индексный seek, моментальный даже на миллиардах строк.
  • Изменения данных между страницами не путают позицию — cursor привязан к ID, а не к offset.
  • Это стандарт de-facto для production API: Stripe, GitHub (часть endpoints), Shopify, Slack, Twitter.

Generator:

def paginate_cursor(
    client: httpx.Client,
    url: str,
    *,
    page_size: int = 100,
    cursor_param: str = "cursor",
    cursor_response_key: str = "next_cursor",
    items_key: str = "items",
) -> Iterator[dict[str, Any]]:
    cursor: str | None = None
    while True:
        params: dict[str, Any] = {"limit": page_size}
        if cursor:
            params[cursor_param] = cursor
        response = client.get(url, params=params)
        response.raise_for_status()
        page = response.json()
        yield from page[items_key]
        cursor = page.get(cursor_response_key)
        if not cursor:
            break

Условие выхода — сервер не вернул next_cursor (либо null, либо ключа просто нет). Это единственный надёжный сигнал «больше страниц нет».

GitHub использует

Link header
. Это RFC 5988, формат:

Link: <https://api.github.com/repositories/81598961/issues?page=2>; rel="next",
      <https://api.github.com/repositories/81598961/issues?page=20>; rel="last"

В ответе на каждый запрос приходит этот заголовок, в котором перечислены ссылки на следующую/предыдущую/первую/последнюю страницы. Клиент не строит URL сам — берёт готовый из заголовка и шлёт.

Это удобно, потому что:

  • Cursor зашит в URL, клиент его не парсит.
  • Сервер может менять структуру URL — клиенту всё равно.
  • Совместим с любым внутренним механизмом (cursor, offset, что угодно).

Парсинг Link header:

import re


def parse_link_header(link: str) -> dict[str, str]:
    """Парсит RFC 5988 Link header в словарь {rel: url}."""
    result: dict[str, str] = {}
    for part in link.split(","):
        match = re.match(r'\s*<([^>]+)>\s*;\s*rel="([^"]+)"', part)
        if match:
            url, rel = match.groups()
            result[rel] = url
    return result


def paginate_link(
    client: httpx.Client,
    initial_url: str,
    *,
    params: dict[str, Any] | None = None,
) -> Iterator[dict[str, Any]]:
    """Тянет все страницы по Link header, начиная с initial_url."""
    response = client.get(initial_url, params=params)
    response.raise_for_status()
    yield from response.json()

    while "link" in response.headers:
        links = parse_link_header(response.headers["link"])
        if "next" not in links:
            break
        response = client.get(links["next"])
        response.raise_for_status()
        yield from response.json()

Этот генератор работает для любого GitHub-endpoint, который возвращает массив объектов. Использование:

for issue in paginate_link(client, "/repos/python/cpython/issues", params={"state": "open", "per_page": 100}):
    print(issue["number"], issue["title"])
Четыре паттерна пагинации

Чем правее — тем лучше масштабируется. Cursor и Link — production-grade для больших коллекций.

offset/limit?offset=N&limit=MПростой, но медленный на больших offset. БД делает full scan.
page-based?page=N&per_page=MКосметика поверх offset. Те же проблемы.
cursor?cursor=eyJ...Индексный seek, не ломается от изменений данных.
Link headerLink: <...>; rel="next"Cursor зашит в URL, клиент не строит ссылку сам.

Полный клиент с pagination

Возвращаемся к нашему GitHubClient из прошлого урока. Добавим метод, который тянет все issues, а не первые 100:

import httpx
import re
from collections.abc import Iterator
from typing import Any


class GitHubClient:
    def __init__(self, token: str, *, timeout: float = 30.0) -> None:
        self._client = httpx.Client(
            base_url="https://api.github.com",
            headers={
                "Authorization": f"Bearer {token}",
                "Accept": "application/vnd.github+json",
                "User-Agent": "my-etl/1.0",
                "X-GitHub-Api-Version": "2022-11-28",
            },
            timeout=httpx.Timeout(timeout, connect=5.0),
        )

    def close(self) -> None:
        self._client.close()

    def __enter__(self) -> "GitHubClient":
        return self

    def __exit__(self, *args: object) -> None:
        self.close()

    @staticmethod
    def _parse_link(link: str) -> dict[str, str]:
        result: dict[str, str] = {}
        for part in link.split(","):
            match = re.match(r'\s*<([^>]+)>\s*;\s*rel="([^"]+)"', part)
            if match:
                result[match.group(2)] = match.group(1)
        return result

    def paginate(
        self,
        path: str,
        *,
        params: dict[str, Any] | None = None,
    ) -> Iterator[dict[str, Any]]:
        """Generic GET с разворачиванием pagination по Link header."""
        response = self._client.get(path, params=params)
        response.raise_for_status()
        yield from response.json()

        while link := response.headers.get("link"):
            links = self._parse_link(link)
            if "next" not in links:
                return
            response = self._client.get(links["next"])
            response.raise_for_status()
            yield from response.json()

    def list_all_issues(self, owner: str, repo: str, *, state: str = "open") -> Iterator[dict[str, Any]]:
        yield from self.paginate(
            f"/repos/{owner}/{repo}/issues",
            params={"state": state, "per_page": 100},
        )


with GitHubClient(token="ghp_xxx") as gh:
    count = 0
    for issue in gh.list_all_issues("python", "cpython", state="all"):
        count += 1
    print(f"Total issues: {count}")

Метод paginate() — generic helper. Дальше любой endpoint, который возвращает массив объектов и поддерживает Link header, оборачивается в один метод вроде list_all_issues().

Pagination через генератор: почему это важно

Обратите внимание, что paginate() — это генератор (использует yield from). Это не случайно. Если бы он возвращал list[dict], нам пришлось бы:

  1. Скачать все 50000 issues в память. На больших коллекциях — OOM.
  2. Ждать конца пагинации, прежде чем начать обработку.

С генератором каждый issue выдаётся сразу, как только страница пришла. Обработка идёт параллельно скачиванию. Память — всегда одна страница (плюс overhead Python-объектов).

Это та же идея, что в уроке про generators streaming — ленивый поток данных, никакой материализации.

# OK даже на миллионе issues — память O(page_size)
for issue in gh.list_all_issues("kubernetes", "kubernetes"):
    db.execute("INSERT INTO issues VALUES (?, ?)", (issue["number"], issue["title"]))

Лимиты на стороны клиента

GitHub разрешает 5000 запросов в час для аутентифицированных. Если у вас репозиторий с 60000 issues и per_page=100, нужно 600 запросов — это 12% часовой квоты. На одну операцию.

Поэтому при пагинации всегда:

  • Используйте максимальный per_page (обычно 100 в GitHub, 500-1000 в других API). Каждый удвоенный размер страницы — вдвое меньше запросов.
  • Следите за X-RateLimit-Remaining в заголовках ответа. Если приближается к нулю — стоп, ждите окно.
  • Кэшируйте ETag-и, чтобы при повторном проходе получать 304 (бесплатно по квоте у GitHub).

Подробно про обработку 429 / Retry-After / экспоненциальный backoff — следующий урок.

Anti-patterns

Антипаттерн 1: построение URL вручную поверх Link header.

# ПЛОХО — игнорируем заголовок, считаем page сами
for page in range(1, 100):
    response = client.get(f"/issues?page={page}")
    if not response.json():
        break

Если API изменит схему URL (добавит обязательный sort, переедет на cursor) — код сломается. Используйте Link header, если он есть.

Антипаттерн 2: пагинация без выхода.

# ПЛОХО — что если API всегда возвращает не пустой результат?
while True:
    response = client.get(url, params={"page": page})
    yield from response.json()
    page += 1

Всегда должно быть два условия выхода: пустая страница и страница меньше per_page. Иначе при баге сервера будете крутить бесконечный цикл.

Антипаттерн 3: материализация всего в list.

# ПЛОХО — OOM на больших коллекциях
all_issues = list(gh.list_all_issues("kubernetes", "kubernetes"))

Если данные не помещаются — пишите потоком (в файл / БД / Kafka), не складывайте в list.

Что мы получили

  • Pagination — это обязательная часть REST-клиента к любому большому источнику.
  • Четыре паттерна: offset/limit (простой/медленный), page-based (то же), cursor (production-grade), Link header (cursor в URL).
  • Условие выхода — пустая страница ИЛИ страница меньше per_page ИЛИ нет next cursor/link.
  • Generator (yield from) — обязательно, чтобы не материализовать всё в память.
  • Лимиты сервера: всегда максимальный per_page, следите за X-RateLimit-Remaining.

В следующем уроке — что делать, когда сервер таки сказал 429, или сеть моргнула, или возвращается 502.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7