Skip to content

Commit de61d0e

Browse files
committed
fix: Stop the paginator when a page cursor repeats
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY
1 parent 978a167 commit de61d0e

2 files changed

Lines changed: 80 additions & 30 deletions

File tree

seam/paginator.py

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -87,28 +87,32 @@ def next_page(
8787
def flatten_to_list(self) -> List[Any]:
8888
"""Fetches all pages and returns all items as a single list."""
8989
all_items = []
90-
current_items, pagination = self.first_page()
91-
92-
if current_items:
90+
for current_items in self._walk():
9391
all_items.extend(current_items)
9492

95-
while pagination and pagination.has_next_page and pagination.next_page_cursor:
96-
current_items, pagination = self.next_page(pagination.next_page_cursor)
97-
if current_items:
98-
all_items.extend(current_items)
99-
10093
return all_items
10194

10295
def flatten(self) -> Generator[Any, None, None]:
10396
"""Fetches all pages and yields items one by one using a generator."""
104-
current_items, pagination = self.first_page()
105-
if current_items:
97+
for current_items in self._walk():
10698
yield from current_items
10799

100+
def _walk(self) -> Generator[List[Any], None, None]:
101+
"""Yields each page once, stopping if the server repeats a cursor."""
102+
current_items, pagination = self.first_page()
103+
yield current_items or []
104+
105+
seen_cursors = set()
106+
108107
while pagination and pagination.has_next_page and pagination.next_page_cursor:
109-
current_items, pagination = self.next_page(pagination.next_page_cursor)
110-
if current_items:
111-
yield from current_items
108+
cursor = pagination.next_page_cursor
109+
110+
if cursor in seen_cursors:
111+
return
112+
seen_cursors.add(cursor)
113+
114+
current_items, pagination = self.next_page(cursor)
115+
yield current_items or []
112116

113117

114118
class AsyncSeamPaginator:
@@ -161,29 +165,30 @@ async def next_page(
161165
async def flatten_to_list(self) -> List[Any]:
162166
"""Fetches all pages and returns all items as a single list."""
163167
all_items = []
164-
current_items, pagination = await self.first_page()
165-
166-
if current_items:
168+
async for current_items in self._walk():
167169
all_items.extend(current_items)
168170

169-
while pagination and pagination.has_next_page and pagination.next_page_cursor:
170-
current_items, pagination = await self.next_page(
171-
pagination.next_page_cursor
172-
)
173-
if current_items:
174-
all_items.extend(current_items)
175-
176171
return all_items
177172

178173
async def flatten(self) -> AsyncGenerator[Any, None]:
179174
"""Fetches all pages and yields items one by one using an async generator."""
175+
async for current_items in self._walk():
176+
for item in current_items:
177+
yield item
178+
179+
async def _walk(self) -> AsyncGenerator[List[Any], None]:
180+
"""Yields each page once, stopping if the server repeats a cursor."""
180181
current_items, pagination = await self.first_page()
181-
for item in current_items or []:
182-
yield item
182+
yield current_items or []
183+
184+
seen_cursors = set()
183185

184186
while pagination and pagination.has_next_page and pagination.next_page_cursor:
185-
current_items, pagination = await self.next_page(
186-
pagination.next_page_cursor
187-
)
188-
for item in current_items or []:
189-
yield item
187+
cursor = pagination.next_page_cursor
188+
189+
if cursor in seen_cursors:
190+
return
191+
seen_cursors.add(cursor)
192+
193+
current_items, pagination = await self.next_page(cursor)
194+
yield current_items or []

test/paginator_isolation_test.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,48 @@ async def test_a_missing_pagination_envelope_raises_async(recording_server):
9292
"pagination object",
9393
):
9494
await paginator.first_page()
95+
96+
97+
PINNED_CURSOR_PAGE = {
98+
"devices": [{"device_id": "33333333-3333-3333-3333-333333333333"}],
99+
"pagination": {
100+
"has_next_page": True,
101+
"next_page_cursor": "pinned-cursor",
102+
"next_page_url": "https://example.com/devices/list?page_cursor=pinned-cursor",
103+
},
104+
}
105+
106+
107+
def test_paginator_stops_when_a_cursor_repeats(recording_server):
108+
with recording_server([(200, PINNED_CURSOR_PAGE)]) as (endpoint, requests):
109+
seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint)
110+
paginator = seam.create_paginator(seam.devices.list)
111+
112+
devices = paginator.flatten_to_list()
113+
114+
# The server pins one cursor, so the paginator fetches the first
115+
# page, follows the cursor once, sees it repeat, and stops.
116+
assert len(requests) == 2
117+
assert len(devices) == 2
118+
119+
120+
def test_paginator_flatten_stops_when_a_cursor_repeats(recording_server):
121+
with recording_server([(200, PINNED_CURSOR_PAGE)]) as (endpoint, requests):
122+
seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint)
123+
paginator = seam.create_paginator(seam.devices.list)
124+
125+
devices = list(paginator.flatten())
126+
127+
assert len(requests) == 2
128+
assert len(devices) == 2
129+
130+
131+
async def test_paginator_stops_when_a_cursor_repeats_async(recording_server):
132+
with recording_server([(200, PINNED_CURSOR_PAGE)]) as (endpoint, requests):
133+
async with AsyncSeam(api_key="seam_apikey_token", endpoint=endpoint) as seam:
134+
paginator = seam.create_paginator(seam.devices.list)
135+
136+
devices = await paginator.flatten_to_list()
137+
138+
assert len(requests) == 2
139+
assert len(devices) == 2

0 commit comments

Comments
 (0)