Skip to content

Commit e81c2a9

Browse files
committed
fix: Stop cursor-paginated iterators on the cursor, not on empty pages
1 parent a472d24 commit e81c2a9

2 files changed

Lines changed: 17 additions & 17 deletions

File tree

src/apify_client/_pagination.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,11 @@ def get_cursor_iterator(
129129
"""Yield individual items from cursor-paginated API responses.
130130
131131
Cursor pagination is restricted to the two API responses that expose it: `ListOfKeys` (for key-value store keys) and
132-
`ListOfRequests` (for request queue requests). Iteration ends when a page scans no items, the next cursor is `None`,
133-
or the user-requested `limit` is reached.
132+
`ListOfRequests` (for request queue requests). Iteration ends when the next cursor is `None` or the user-requested
133+
`limit` is reached. Emptiness alone does not stop iteration: server-side filters (such as the request-queue state
134+
`filter`) can drop every item on a page while a live cursor still points at more data, so termination relies on the
135+
cursor, not on whether a page returned items. Unlike offset responses, cursor responses expose no scanned-item
136+
`count`, so `count` cannot be used to detect a fully-filtered page here.
134137
135138
Args:
136139
callback: Function returning a single page of items. Receives `cursor` and `limit` kwargs.
@@ -149,13 +152,12 @@ def get_cursor_iterator(
149152
)
150153
yield from current_page.items
151154

152-
page_scanned = max(getattr(current_page, 'count', 0), len(current_page.items))
153-
fetched_items += page_scanned
155+
fetched_items += len(current_page.items)
154156
cursor = (
155157
current_page.next_exclusive_start_key if isinstance(current_page, ListOfKeys) else current_page.next_cursor
156158
)
157159

158-
if not page_scanned or cursor is None or (initial_limit and fetched_items >= initial_limit):
160+
if cursor is None or (initial_limit and fetched_items >= initial_limit):
159161
break
160162

161163

@@ -195,13 +197,12 @@ async def get_cursor_iterator_async(
195197
for item in current_page.items:
196198
yield item
197199

198-
page_scanned = max(getattr(current_page, 'count', 0), len(current_page.items))
199-
fetched_items += page_scanned
200+
fetched_items += len(current_page.items)
200201
cursor = (
201202
current_page.next_exclusive_start_key if isinstance(current_page, ListOfKeys) else current_page.next_cursor
202203
)
203204

204-
if not page_scanned or cursor is None or (initial_limit and fetched_items >= initial_limit):
205+
if cursor is None or (initial_limit and fetched_items >= initial_limit):
205206
break
206207

207208

tests/unit/test_client_pagination.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -634,11 +634,10 @@ def __init__(self, items: list[dict[str, int]], count: int) -> None:
634634

635635

636636
class _FakeCursorPage:
637-
"""Cursor-paginated page whose `count` (items scanned) may exceed `len(items)` when filters drop items."""
637+
"""Cursor-paginated page mirroring `ListOfRequests`: no scanned-`count`, so a filtered page is just `items=[]`."""
638638

639-
def __init__(self, items: list[dict[str, int]], count: int, next_cursor: str | None) -> None:
639+
def __init__(self, items: list[dict[str, int]], next_cursor: str | None) -> None:
640640
self.items = items
641-
self.count = count
642641
self.next_cursor = next_cursor
643642

644643

@@ -669,10 +668,10 @@ async def _callback(*, limit: int | None = None, offset: int | None = None) -> _
669668

670669

671670
def test_cursor_iterator_continues_past_fully_filtered_page() -> None:
672-
"""A fully-filtered page (`items=[]`, `count>0`) with a live cursor must not stop the cursor iterator."""
671+
"""A fully-filtered page (`items=[]`) with a live cursor must not stop the cursor iterator."""
673672
pages = {
674-
None: _FakeCursorPage(items=[], count=1000, next_cursor='c1'),
675-
'c1': _FakeCursorPage(items=[{'id': 1}, {'id': 2}], count=2, next_cursor=None),
673+
None: _FakeCursorPage(items=[], next_cursor='c1'),
674+
'c1': _FakeCursorPage(items=[{'id': 1}, {'id': 2}], next_cursor=None),
676675
}
677676

678677
def _callback(*, limit: int | None = None, cursor: str | None = None) -> _FakeCursorPage: # noqa: ARG001
@@ -682,10 +681,10 @@ def _callback(*, limit: int | None = None, cursor: str | None = None) -> _FakeCu
682681

683682

684683
async def test_cursor_iterator_async_continues_past_fully_filtered_page() -> None:
685-
"""A fully-filtered page (`items=[]`, `count>0`) with a live cursor must not stop the async cursor iterator."""
684+
"""A fully-filtered page (`items=[]`) with a live cursor must not stop the async cursor iterator."""
686685
pages = {
687-
None: _FakeCursorPage(items=[], count=1000, next_cursor='c1'),
688-
'c1': _FakeCursorPage(items=[{'id': 1}, {'id': 2}], count=2, next_cursor=None),
686+
None: _FakeCursorPage(items=[], next_cursor='c1'),
687+
'c1': _FakeCursorPage(items=[{'id': 1}, {'id': 2}], next_cursor=None),
689688
}
690689

691690
async def _callback(*, limit: int | None = None, cursor: str | None = None) -> _FakeCursorPage: # noqa: ARG001

0 commit comments

Comments
 (0)