Skip to content

Commit 1596ed9

Browse files
vdusekclaude
andcommitted
fix: Prevent iterate_items from terminating early under server-side filters
When `skip_empty`, `skip_hidden`, or `clean=True` is used, the API post-filters each scanned window, so a page with fewer than 1000 items does not imply the dataset is exhausted. Track the scanned window explicitly, advance the offset by the scanned size (not the returned count), and terminate only on an empty response. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6c3b84d commit 1596ed9

2 files changed

Lines changed: 77 additions & 13 deletions

File tree

src/apify_client/_resource_clients/dataset.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -268,21 +268,24 @@ def iterate_items(
268268
"""
269269
cache_size = 1000
270270

271-
should_finish = False
272271
read_items = 0
272+
# Server-side filters (skip_empty, skip_hidden, clean) drop items after the [offset, offset+limit)
273+
# window is scanned, so a short page does not imply the dataset is exhausted. Track the scanned
274+
# window explicitly and stop only when the server returns no items at all.
275+
scanned_items = 0
273276

274277
# We can't rely on DatasetItemsPage.total because that is updated with a delay,
275278
# so if you try to read the dataset items right after a run finishes, you could miss some.
276279
# Instead, we just read and read until we reach the limit, or until there are no more items to read.
277-
while not should_finish:
280+
while True:
278281
effective_limit = cache_size
279282
if limit is not None:
280283
if read_items == limit:
281284
break
282285
effective_limit = min(cache_size, limit - read_items)
283286

284287
current_items_page = self.list_items(
285-
offset=offset + read_items,
288+
offset=offset + scanned_items,
286289
limit=effective_limit,
287290
clean=clean,
288291
desc=desc,
@@ -298,10 +301,11 @@ def iterate_items(
298301
yield from current_items_page.items
299302

300303
current_page_item_count = len(current_items_page.items)
301-
read_items += current_page_item_count
304+
if current_page_item_count == 0:
305+
break
302306

303-
if current_page_item_count < cache_size:
304-
should_finish = True
307+
read_items += current_page_item_count
308+
scanned_items += effective_limit
305309

306310
def download_items(
307311
self,
@@ -945,21 +949,24 @@ async def iterate_items(
945949
"""
946950
cache_size = 1000
947951

948-
should_finish = False
949952
read_items = 0
953+
# Server-side filters (skip_empty, skip_hidden, clean) drop items after the [offset, offset+limit)
954+
# window is scanned, so a short page does not imply the dataset is exhausted. Track the scanned
955+
# window explicitly and stop only when the server returns no items at all.
956+
scanned_items = 0
950957

951958
# We can't rely on DatasetItemsPage.total because that is updated with a delay,
952959
# so if you try to read the dataset items right after a run finishes, you could miss some.
953960
# Instead, we just read and read until we reach the limit, or until there are no more items to read.
954-
while not should_finish:
961+
while True:
955962
effective_limit = cache_size
956963
if limit is not None:
957964
if read_items == limit:
958965
break
959966
effective_limit = min(cache_size, limit - read_items)
960967

961968
current_items_page = await self.list_items(
962-
offset=offset + read_items,
969+
offset=offset + scanned_items,
963970
limit=effective_limit,
964971
clean=clean,
965972
desc=desc,
@@ -976,10 +983,11 @@ async def iterate_items(
976983
yield item
977984

978985
current_page_item_count = len(current_items_page.items)
979-
read_items += current_page_item_count
986+
if current_page_item_count == 0:
987+
break
980988

981-
if current_page_item_count < cache_size:
982-
should_finish = True
989+
read_items += current_page_item_count
990+
scanned_items += effective_limit
983991

984992
async def get_items_as_bytes(
985993
self,

tests/unit/test_dataset_list_items.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import TYPE_CHECKING
55

66
import pytest
7-
from werkzeug import Response
7+
from werkzeug import Request, Response
88

99
from apify_client import ApifyClient, ApifyClientAsync
1010

@@ -87,3 +87,59 @@ async def test_list_items_desc_true_async(httpserver: HTTPServer, desc_header_va
8787
result = await client.dataset(DATASET_ID).list_items()
8888

8989
assert result.desc is True
90+
91+
92+
def _make_filtered_pagination_handler(*, dataset_size: int) -> Callable:
93+
"""Simulate an API that scans `[offset, offset+limit)` then post-filters out odd-indexed items.
94+
95+
Each returned page therefore contains ~half of the scanned window — a situation that
96+
only `skip_empty`, `skip_hidden`, or `clean=True` can produce server-side.
97+
"""
98+
99+
def handler(request: Request) -> Response:
100+
offset = int(request.args.get('offset', '0'))
101+
limit = int(request.args.get('limit', '1000'))
102+
scanned_end = min(offset + limit, dataset_size)
103+
items = [{'i': i} for i in range(offset, scanned_end) if i % 2 == 0]
104+
return Response(
105+
status=200,
106+
headers={
107+
'x-apify-pagination-total': str(dataset_size),
108+
'x-apify-pagination-offset': str(offset),
109+
'x-apify-pagination-count': str(len(items)),
110+
'x-apify-pagination-limit': str(limit),
111+
'x-apify-pagination-desc': 'false',
112+
'content-type': 'application/json',
113+
},
114+
response=json.dumps(items),
115+
)
116+
117+
return handler
118+
119+
120+
def test_iterate_items_with_filter_does_not_terminate_early_sync(httpserver: HTTPServer) -> None:
121+
dataset_size = 2500
122+
httpserver.expect_request(ITEMS_PATH).respond_with_handler(
123+
_make_filtered_pagination_handler(dataset_size=dataset_size),
124+
)
125+
api_url = httpserver.url_for('/').removesuffix('/')
126+
127+
client = ApifyClient(token='test-token', api_url=api_url)
128+
items = list(client.dataset(DATASET_ID).iterate_items(skip_empty=True))
129+
130+
expected = [{'i': i} for i in range(dataset_size) if i % 2 == 0]
131+
assert items == expected
132+
133+
134+
async def test_iterate_items_with_filter_does_not_terminate_early_async(httpserver: HTTPServer) -> None:
135+
dataset_size = 2500
136+
httpserver.expect_request(ITEMS_PATH).respond_with_handler(
137+
_make_filtered_pagination_handler(dataset_size=dataset_size),
138+
)
139+
api_url = httpserver.url_for('/').removesuffix('/')
140+
141+
client = ApifyClientAsync(token='test-token', api_url=api_url)
142+
items = [item async for item in client.dataset(DATASET_ID).iterate_items(skip_empty=True)]
143+
144+
expected = [{'i': i} for i in range(dataset_size) if i % 2 == 0]
145+
assert items == expected

0 commit comments

Comments
 (0)