Почему API не отдаёт всё сразу
В прошлом уроке мы сделали gh.list_issues("python", "cpython"). Если у проекта 10 issues — всё работает. Если у проекта 50000 — наша функция вернёт первые 100 (per_page=100) и тихо потеряет остальные 49900.
Это не баг библиотеки. Это сознательное решение серверной стороны: ни один разумный API не отдаст 50000 объектов одним JSON-ом. Это:
- Гигабайты трафика → таймауты у клиента и нагрузка на сервер.
- Долгий запрос держит коннект и БД-курсор — соседи API получают 503.
- Клиент не сможет показать прогресс, обработать данные потоком, продолжить после ошибки.
Поэтому каждый production API ограничивает страницу (обычно 50-500 элементов) и даёт способ запросить следующую. Этот способ называется
Существуют четыре основных паттерна. 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}
Преимущества огромные:
- Сервер делает
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, либо ключа просто нет). Это единственный надёжный сигнал «больше страниц нет».
Паттерн 4: Link header (GitHub-style)
GitHub использует
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 для больших коллекций.
Полный клиент с 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], нам пришлось бы:
- Скачать все 50000 issues в память. На больших коллекциях — OOM.
- Ждать конца пагинации, прежде чем начать обработку.
С генератором каждый 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ИЛИ нетnextcursor/link. - Generator (
yield from) — обязательно, чтобы не материализовать всё в память. - Лимиты сервера: всегда максимальный
per_page, следите заX-RateLimit-Remaining.
В следующем уроке — что делать, когда сервер таки сказал 429, или сеть моргнула, или возвращается 502.