Практика: постраничный обход API заказов (Paginated iterator)
Итератор прячет пагинацию внешнего API: клиент видит один длинный поток заказов, а следующая страница запрашивается, только когда до неё дошли.
Базовый пример: Итератор. Там дерево целиком лежит в памяти, и ленивость обхода ничего не экономит. Здесь каждый шаг за границу страницы — сетевой запрос, и вопросы другие: когда именно запрашивать, что будет при раннем выходе и что делать с ошибкой на середине.
Задача
Сервис выгрузки забирает заказы из API маркетплейса. API отдаёт их страницами по курсору: в ответе заказы и nextCursor, который надо передать в следующий запрос; на последней странице курсора нет. Первая версия выгрузки собирала все страницы в массив и только потом обрабатывала. На большом магазине это десятки тысяч заказов в памяти, а отчёт «последние 20 заказов» ждал, пока скачаются все страницы, и тратил лимит запросов к API.
Цикл по курсору при этом повторялся в трёх местах: в выгрузке, в отчёте и в сверке остатков. В одном из них забыли проверить последнюю страницу, и цикл зациклился на пустом курсоре.
Что урезано
- API — заглушка в памяти: словарь «курсор → страница» и счётчик запросов. В проекте это HTTP-клиент с авторизацией.
- Нет повторов при сбое, пауз при лимите запросов (
429) и таймаутов: ошибка страницы сразу заканчивает обход. - Нет контекста отмены (
context.Context,AbortSignal): ранний выход — толькоbreak. - Заказ — два поля, без статусов, позиций и валют.
Решение
Контракт описывает, что умеет API: fetchPage(cursor) возвращает Page — заказы и курсор следующей страницы. Фейковый API FakeOrdersApi отдаёт три страницы (2 + 2 + 1 заказ) и считает запросы, чтобы тесты видели каждое обращение.
Коллекция PagedOrders — та же роль, что CategoryTree в базовом примере: клиент проходит её циклом и не знает, как она устроена. Внутри генератор: запросить страницу, отдать её заказы по одному, взять курсор, повторить. Запрос следующей страницы стоит в коде после последнего yield текущей, поэтому выполняется, только когда клиент попросил следующий заказ. Создание PagedOrders не делает ни одного запроса: генератор начинает работать при первом шаге цикла.
Ошибка API на середине оборачивается в PageFetchFailed (в Python и TS — PageFetchError, в Go — обёртка %w) с курсором страницы: из сообщения видно, на какой странице оборвалось. Уже отданные заказы клиент получил, и что с ними делать, решает он.
Структура
Контракт — всё, что видит коллекция: интерфейс API, страница, заказ и две ошибки. ApiUnavailable бросает сам API, PageFetchFailed / PageFetchError — коллекция, добавляя к причине курсор. В Go вместо классов сентинел ErrUnavailable и обёртка через %w. Первая страница запрашивается без курсора (null, в Go — пустая строка), отсутствие nextCursor значит последнюю страницу.
Фейковый API
Заглушка — главный инструмент тестов. Она считает каждый вызов fetchPage в requests, и тест проверяет не только какие заказы получены, но и сколько запросов на это ушло. Параметр failOn заставляет её упасть на заданном курсоре: так ошибку на середине обхода можно воспроизвести без сети и без случайности.
Как это собирается
Клиент API создаётся один раз в контейнере сервиса, а PagedOrders - там, где нужен обход: в обработчике страницы или в задаче по расписанию. В примере два потребителя одного API. Виджет на дашборде берёт первые три заказа и выходит из цикла: страница p3 не запрашивается, и на всё уходит 2 запроса. Ночной отчёт проходит заказы до конца и суммирует их.
Во втором случае API падает на странице p3. Отчёт ловит PageFetchFailed, но уже посчитанные четыре заказа не теряет: в результат попадает частичная сумма и текст ошибки с курсором страницы, с которой задачу можно перезапустить. В приложении клиент один, а сбой эмулирует второй фейковый API с failOn.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Коллекция в каждом языке — генератор, но форма ошибки и ожидания разная. Синхронные PHP, Go и Python просто блокируются на запросе; в TypeScript сеть асинхронна, и коллекция становится асинхронным итератором, по которому идут for await.
| Язык | Особенность |
|---|---|
| PHP | IteratorAggregate с генератором; ошибка — PageFetchFailed с исходной в previous |
| Go | iter.Seq2[Order, error]: ошибка приходит вторым значением в range, после неё обход заканчивается |
| Python | __iter__ — генератор с yield from page.orders; ошибка — PageFetchError через raise ... from e; ранний выход в тестах — itertools.islice |
| TypeScript | async *[Symbol.asyncIterator] и for await; ошибка — PageFetchError с причиной в { cause } |
Запуск: make test T=patterns/behavioral/iterator/practice
Что проверяют тесты
Тесты одинаковы во всех языках: API с заказами A-1...A-5 на трёх страницах (2 + 2 + 1), клиент берёт первые N заказов и останавливается. Таблица случаев считает запросы. Ничего не прочитали — ноль запросов: создание коллекции в сеть не ходит. Два заказа — один запрос: вторая страница не нужна, и её не просят, хотя первая кончилась. Три заказа — два запроса: третья страница не загружена. Все заказы — три запроса, ровно по одному на страницу. Отдельный тест — ошибка на второй странице: клиент успел получить A-1 и A-2, затем пришла ошибка fetch orders page p2: service unavailable с исходной причиной внутри, запросов было два.
Где граница
- Повторный проход — снова в сеть. Каждый цикл по
PagedOrdersначинает с первой страницы. Если данные нужны дважды, их надо сохранить или поставить перед API кэширующего заместителя. - Сбои и лимиты. Настоящему клиенту нужны повторы с паузой, обработка
429и таймаут. Это обёртка вокругfetchPage(декоратор), а не забота итератора: он только решает, когда запросить следующую страницу. - Продолжение после сбоя. Сейчас обход после ошибки начинается заново. Для длинных выгрузок курсор последней успешной страницы сохраняют и начинают с него — тогда курсор становится частью публичного API коллекции.
- Параллельная загрузка. Ленивый итератор грузит страницы строго по одной. Если важна скорость, а не экономия запросов, следующую страницу запрашивают заранее, пока клиент обрабатывает текущую, или переходят на потоки и каналы.
- Избыточность. Если API отдаёт всё одним ответом или данных заведомо немного, обычный массив проще итератора.
Отличия между языками
- PHP — генератор и есть ленивость: код после
yieldне выполнится, покаforeachне попросит следующий элемент. ГотовыйLimitIteratorдля «первых N» здесь не подходит: после последнего элемента он всё равно вызываетnext()у генератора и тем самым запрашивает лишнюю страницу. Поэтому клиент в тестах выходит изforeachчерезbreak. - Go — у
iter.Seqнет способа вернуть ошибку, поэтому для обхода, который может сломаться, используютiter.Seq2[T, error]: клиент проверяетerrна каждом шаге. После ошибки итератор обязан закончить, иначе клиент получит смесь ошибок и данных. - Python —
itertools.islice(orders, n)берёт n элементов и не просит (n+1)-й, поэтому лишняя страница не грузится.islice(..., None)берёт всё, это удобно для таблицы случаев. - TypeScript —
fetchасинхронный, поэтому синхронныйSymbol.iteratorтут не подходит: нуженSymbol.asyncIteratorиfor await.breakвfor awaitвызываетreturn()у генератора, и следующий запрос не уходит.
Связанные темы
- Итератор (базовый пример)
- Практика Заместителя: кэш репозитория товаров — если одни и те же страницы нужны много раз
- Практика Декоратора: клиент курсов валют — повторы и кэш вокруг внешнего API, которые итератор оставляет за скобками