Skip to content

Commit 80908e4

Browse files
committed
fix: continue offset pagination past empty pages
1 parent 8f33af9 commit 80908e4

2 files changed

Lines changed: 60 additions & 4 deletions

File tree

‎src/kernel/pagination.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ def next_page_info(self) -> Optional[PageInfo]:
133133
if next_offset == 0:
134134
return None
135135

136+
# A next offset at or before the one this page was requested at means no
137+
# progress (e.g. an empty page after rows vanished mid-request); stop
138+
# instead of re-requesting the same page.
139+
request_offset = self._options.params.get("offset")
140+
if isinstance(request_offset, int) and next_offset <= request_offset:
141+
return None
142+
136143
return PageInfo(params={"offset": next_offset})
137144

138145
@classmethod
@@ -184,6 +191,13 @@ def next_page_info(self) -> Optional[PageInfo]:
184191
if next_offset == 0:
185192
return None
186193

194+
# A next offset at or before the one this page was requested at means no
195+
# progress (e.g. an empty page after rows vanished mid-request); stop
196+
# instead of re-requesting the same page.
197+
request_offset = self._options.params.get("offset")
198+
if isinstance(request_offset, int) and next_offset <= request_offset:
199+
return None
200+
187201
return PageInfo(params={"offset": next_offset})
188202

189203
@classmethod

‎tests/test_pagination.py‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import httpx
44
import pytest
55

6+
from kernel import Kernel, AsyncKernel
7+
from kernel._models import FinalRequestOptions
68
from kernel.pagination import SyncOffsetPagination, AsyncOffsetPagination
79

810
PageClass = Union[Type[SyncOffsetPagination[Any]], Type[AsyncOffsetPagination[Any]]]
@@ -12,14 +14,31 @@
1214
both_classes = pytest.mark.parametrize("cls", [SyncOffsetPagination, AsyncOffsetPagination])
1315

1416

15-
def _page(cls: PageClass, *, items: List[Any], next_offset: Optional[int], has_more: Optional[bool]) -> Any:
17+
def client_for(cls: PageClass) -> Any:
18+
if cls is SyncOffsetPagination:
19+
return Kernel(api_key="test-api-key", base_url="http://127.0.0.1")
20+
return AsyncKernel(api_key="test-api-key", base_url="http://127.0.0.1")
21+
22+
23+
def _page(
24+
cls: PageClass,
25+
*,
26+
items: List[Any],
27+
next_offset: Optional[int],
28+
has_more: Optional[bool],
29+
request_offset: Optional[int] = None,
30+
) -> Any:
1631
headers: dict[str, str] = {}
1732
if next_offset is not None:
1833
headers["X-Next-Offset"] = str(next_offset)
1934
if has_more is not None:
2035
headers["X-Has-More"] = "true" if has_more else "false"
2136
response = httpx.Response(200, headers=headers)
22-
return cls.build(response=response, data=items)
37+
page = cls.build(response=response, data=items)
38+
params = {} if request_offset is None else {"offset": request_offset}
39+
options = FinalRequestOptions(method="get", url="/proxies", params=params)
40+
page._set_private_attributes(client=client_for(cls), model=object, options=options) # type: ignore[arg-type]
41+
return page
2342

2443

2544
@both_classes
@@ -54,8 +73,31 @@ def test_stops_on_next_offset_zero_sentinel(cls: PageClass) -> None:
5473

5574

5675
@both_classes
57-
def test_stops_on_empty_page(cls: PageClass) -> None:
58-
page = _page(cls, items=[], next_offset=300, has_more=True)
76+
def test_continues_past_empty_page_while_x_has_more_true(cls: PageClass) -> None:
77+
# A server-side filter can leave a page empty with more records ahead.
78+
page = _page(cls, items=[], next_offset=300, has_more=True, request_offset=200)
79+
assert page.has_next_page() is True
80+
info = page.next_page_info()
81+
assert info is not None
82+
assert info.params == {"offset": 300}
83+
84+
85+
@both_classes
86+
def test_stops_on_empty_page_when_x_has_more_false(cls: PageClass) -> None:
87+
page = _page(cls, items=[], next_offset=0, has_more=False, request_offset=200)
88+
assert page.has_next_page() is False
89+
90+
91+
@both_classes
92+
def test_stops_when_next_offset_does_not_advance(cls: PageClass) -> None:
93+
page = _page(cls, items=[], next_offset=200, has_more=True, request_offset=200)
94+
assert page.next_page_info() is None
95+
assert page.has_next_page() is False
96+
97+
98+
@both_classes
99+
def test_stops_on_empty_first_page_without_pagination_headers(cls: PageClass) -> None:
100+
page = _page(cls, items=[], next_offset=None, has_more=None)
59101
assert page.has_next_page() is False
60102

61103

0 commit comments

Comments
 (0)