Skip to content

Commit c0a3e14

Browse files
IlyaasKclaude
andcommitted
fix(pagination): stop skipping a page per auto-pagination iteration
Sync/AsyncOffsetPagination.next_page_info() requested the next page at next_offset + len(items). The X-Next-Offset header already holds the offset where the next page starts (the API computes offset + limit), so adding the current page length skipped a full page per iteration: with 250 items at limit 100, iteration returned items 0-99 and 200-249 and silently dropped 100-199. X-Has-More still terminated the loop, hiding the data loss. Use the header value directly. Absent header already terminated via the None check, unchanged. Hand-maintained fix: with Stainless's hosted generator winding down, the template-level fix is not coming; this commit and its test are the canonical behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 90cb376 commit c0a3e14

2 files changed

Lines changed: 48 additions & 8 deletions

File tree

src/kernel/pagination.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,9 @@ def next_page_info(self) -> Optional[PageInfo]:
4242
if next_offset is None:
4343
return None # type: ignore[unreachable]
4444

45-
length = len(self._get_page_items())
46-
current_count = next_offset + length
47-
48-
return PageInfo(params={"offset": current_count})
45+
# X-Next-Offset already holds the offset where the next page starts;
46+
# adding the current page length on top skips a full page per iteration.
47+
return PageInfo(params={"offset": next_offset})
4948

5049
@classmethod
5150
def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003
@@ -85,10 +84,9 @@ def next_page_info(self) -> Optional[PageInfo]:
8584
if next_offset is None:
8685
return None # type: ignore[unreachable]
8786

88-
length = len(self._get_page_items())
89-
current_count = next_offset + length
90-
91-
return PageInfo(params={"offset": current_count})
87+
# X-Next-Offset already holds the offset where the next page starts;
88+
# adding the current page length on top skips a full page per iteration.
89+
return PageInfo(params={"offset": next_offset})
9290

9391
@classmethod
9492
def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003

tests/test_pagination.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from typing import Any, List, Optional
2+
3+
import httpx
4+
5+
from kernel.pagination import SyncOffsetPagination
6+
7+
8+
def _page(
9+
*, items: List[Any], next_offset: Optional[int], has_more: Optional[bool]
10+
) -> SyncOffsetPagination[Any]:
11+
headers: dict[str, str] = {}
12+
if next_offset is not None:
13+
headers["X-Next-Offset"] = str(next_offset)
14+
if has_more is not None:
15+
headers["X-Has-More"] = "true" if has_more else "false"
16+
response = httpx.Response(200, headers=headers)
17+
return SyncOffsetPagination.build(response=response, data=items)
18+
19+
20+
def test_next_page_starts_at_exactly_x_next_offset() -> None:
21+
# X-Next-Offset already holds the next page's start. Adding the current
22+
# page length on top (the old behavior) skipped a full page per iteration.
23+
page = _page(items=[{}] * 100, next_offset=100, has_more=True)
24+
info = page.next_page_info()
25+
assert info is not None
26+
assert info.params == {"offset": 100}
27+
28+
29+
def test_stops_when_x_next_offset_absent() -> None:
30+
page = _page(items=[{}] * 100, next_offset=None, has_more=True)
31+
assert page.next_page_info() is None
32+
assert page.has_next_page() is False
33+
34+
35+
def test_stops_when_x_has_more_false() -> None:
36+
page = _page(items=[{}] * 50, next_offset=200, has_more=False)
37+
assert page.has_next_page() is False
38+
39+
40+
def test_stops_on_empty_page() -> None:
41+
page = _page(items=[], next_offset=300, has_more=True)
42+
assert page.has_next_page() is False

0 commit comments

Comments
 (0)