Skip to content

Commit e0d58f6

Browse files
authored
Merge pull request #119 from kernel/fix-offset-pagination-page-skip
Fix offset auto-pagination skipping every other page
2 parents 90cb376 + 5d2afd7 commit e0d58f6

3 files changed

Lines changed: 94 additions & 12 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,8 @@ first_page = await client.deployments.list(
185185
)
186186

187187
print(
188-
f"the current start offset for this page: {first_page.next_offset}"
189-
) # => "the current start offset for this page: 1"
188+
f"the offset where the next page starts: {first_page.next_offset}"
189+
) # => "the offset where the next page starts: 2"
190190
for deployment in first_page.items:
191191
print(deployment.id)
192192

src/kernel/pagination.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,20 @@ def has_next_page(self) -> bool:
4040
def next_page_info(self) -> Optional[PageInfo]:
4141
next_offset = self.next_offset
4242
if next_offset is None:
43-
return None # type: ignore[unreachable]
43+
if self.has_more: # type: ignore[unreachable]
44+
raise RuntimeError(
45+
"Server reported X-Has-More: true without an X-Next-Offset header; "
46+
"refusing to silently truncate pagination"
47+
)
48+
return None
4449

45-
length = len(self._get_page_items())
46-
current_count = next_offset + length
50+
# X-Next-Offset is the next page's absolute start, or 0 on the last page
51+
# (the API's stop sentinel). Only a positive offset advances; the old code
52+
# added the current page length on top, skipping a page per iteration.
53+
if next_offset == 0:
54+
return None
4755

48-
return PageInfo(params={"offset": current_count})
56+
return PageInfo(params={"offset": next_offset})
4957

5058
@classmethod
5159
def build(cls: Type[_BaseModelT], *, response: Response, data: object) -> _BaseModelT: # noqa: ARG003
@@ -83,12 +91,20 @@ def has_next_page(self) -> bool:
8391
def next_page_info(self) -> Optional[PageInfo]:
8492
next_offset = self.next_offset
8593
if next_offset is None:
86-
return None # type: ignore[unreachable]
87-
88-
length = len(self._get_page_items())
89-
current_count = next_offset + length
90-
91-
return PageInfo(params={"offset": current_count})
94+
if self.has_more: # type: ignore[unreachable]
95+
raise RuntimeError(
96+
"Server reported X-Has-More: true without an X-Next-Offset header; "
97+
"refusing to silently truncate pagination"
98+
)
99+
return None
100+
101+
# X-Next-Offset is the next page's absolute start, or 0 on the last page
102+
# (the API's stop sentinel). Only a positive offset advances; the old code
103+
# added the current page length on top, skipping a page per iteration.
104+
if next_offset == 0:
105+
return None
106+
107+
return PageInfo(params={"offset": next_offset})
92108

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

tests/test_pagination.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from typing import Any, List, Type, Union, Optional
2+
3+
import httpx
4+
import pytest
5+
6+
from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination
7+
8+
PageClass = Union[Type[SyncOffsetPagination[Any]], Type[AsyncOffsetPagination[Any]]]
9+
10+
# build() and next_page_info() are plain synchronous methods on both classes,
11+
# so both generated variants are pinned against drifting apart on regeneration.
12+
both_classes = pytest.mark.parametrize("cls", [SyncOffsetPagination, AsyncOffsetPagination])
13+
14+
15+
def _page(cls: PageClass, *, items: List[Any], next_offset: Optional[int], has_more: Optional[bool]) -> Any:
16+
headers: dict[str, str] = {}
17+
if next_offset is not None:
18+
headers["X-Next-Offset"] = str(next_offset)
19+
if has_more is not None:
20+
headers["X-Has-More"] = "true" if has_more else "false"
21+
response = httpx.Response(200, headers=headers)
22+
return cls.build(response=response, data=items)
23+
24+
25+
@both_classes
26+
def test_next_page_starts_at_exactly_x_next_offset(cls: PageClass) -> None:
27+
# X-Next-Offset already holds the next page's start. Adding the current
28+
# page length on top (the old behavior) skipped a full page per iteration.
29+
page = _page(cls, items=[{}] * 100, next_offset=100, has_more=True)
30+
info = page.next_page_info()
31+
assert info is not None
32+
assert info.params == {"offset": 100}
33+
34+
35+
@both_classes
36+
def test_stops_cleanly_when_last_page_omits_x_next_offset(cls: PageClass) -> None:
37+
page = _page(cls, items=[{}] * 50, next_offset=None, has_more=False)
38+
assert page.next_page_info() is None
39+
assert page.has_next_page() is False
40+
41+
42+
@both_classes
43+
def test_stops_when_x_has_more_false(cls: PageClass) -> None:
44+
page = _page(cls, items=[{}] * 50, next_offset=200, has_more=False)
45+
assert page.has_next_page() is False
46+
47+
48+
@both_classes
49+
def test_stops_on_next_offset_zero_sentinel(cls: PageClass) -> None:
50+
# 0 is the API's last-page sentinel. Assert via next_page_info directly,
51+
# with has_more=True, so the has_more gate cannot mask the 0 handling.
52+
page = _page(cls, items=[{}] * 50, next_offset=0, has_more=True)
53+
assert page.next_page_info() is None
54+
55+
56+
@both_classes
57+
def test_stops_on_empty_page(cls: PageClass) -> None:
58+
page = _page(cls, items=[], next_offset=300, has_more=True)
59+
assert page.has_next_page() is False
60+
61+
62+
@both_classes
63+
def test_refuses_to_silently_truncate_on_contradictory_headers(cls: PageClass) -> None:
64+
page = _page(cls, items=[{}] * 100, next_offset=None, has_more=True)
65+
with pytest.raises(RuntimeError, match="refusing to silently truncate"):
66+
page.has_next_page()

0 commit comments

Comments
 (0)