Skip to content

Commit d4b6d0c

Browse files
TexasCodingclaude
andcommitted
review(#135): unbounded by default + eager validation in 7 wrapper methods
Per bot review on PR #135: ### Behavior change: max_pages=None is now truly unbounded Dropped the silent 1000-page cap. None iterates until the server returns no cursor; the existing cursor-repeat guard remains the safety net against infinite loops (cap was never part of the public contract). Bot's recommended path: most users expect None = unbounded and a silent truncation post-release would be a harder break to fix. ### Fix: 7 wrapper methods had lazy validation Methods using `yield from self._list_all(...)` (5 sync) or `async for item in ... yield item` (2 async) were silently generators — their body, including _validate_max_pages, did not execute until the caller advanced the iterator. milestones.list_all(max_pages=0) returned a generator without raising; the ValueError only fired on the first next(). Fixed by converting to `return self._list_all(...)`: - communications.{Sync,Async}.list_all_rfqs - incentive_programs.list_all - milestones.list_all - structured_targets.list_all - subaccounts.{Sync,Async}.list_all_transfers Async ones additionally need `def` (not `async def`) so they're plain functions returning the AsyncIterator, not coroutines. ### Async drift FQNs Added all 19 async resource FQNs to _MAX_PAGES_FQNS in tests/_contract_support.py so the drift framework recognizes the kwarg if coverage is ever extended to async classes. ### Regression tests - TestMaxPagesEagerValidation × 4 (sync + async, both styles) — asserts ValueError fires at call time, not on first __next__. - TestMaxPagesNoneIsUnbounded × 1 — server returns 1100 pages, suite iterates all 1100 (proves the 1000 cap is gone). Verify: 1577 passed (+5). ruff + mypy --strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c5968ab commit d4b6d0c

8 files changed

Lines changed: 119 additions & 30 deletions

File tree

kalshi/resources/_base.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -173,24 +173,24 @@ def _list_all(
173173
convention). ``cursor_key`` only affects how the response envelope
174174
is parsed.
175175
176-
``max_pages`` caps the number of pages fetched as a safety net for
177-
servers that never repeat a cursor but never return empty either.
178-
``None`` (default) means the built-in ceiling of 1000. Callers that
179-
need a tighter cap (e.g. a quick preview) pass it explicitly.
176+
``max_pages`` is an optional hard cap on pages fetched. ``None``
177+
(default) iterates until the server returns an empty cursor; the
178+
cursor-repeat guard below provides the safety net against
179+
infinite loops.
180180
181181
Raises ``KalshiError`` if a cursor value repeats, which indicates
182-
a server-side pagination bug that would otherwise cause the safety
183-
cap to silently issue ``max_pages`` duplicate requests.
182+
a server-side pagination bug.
184183
"""
185-
page_cap = 1000 if max_pages is None else max_pages
186184
current_params = dict(params) if params else {}
187185
seen_cursors: set[str] = set()
188-
for _ in range(page_cap):
186+
pages_fetched = 0
187+
while max_pages is None or pages_fetched < max_pages:
189188
page = self._list(
190189
path, model_cls, items_key,
191190
params=current_params, cursor_key=cursor_key,
192191
)
193192
yield from page.items
193+
pages_fetched += 1
194194
if not page.cursor:
195195
break
196196
if page.cursor in seen_cursors:
@@ -286,20 +286,18 @@ async def _list_all(
286286
max_pages: int | None = None,
287287
cursor_key: str = "cursor",
288288
) -> AsyncIterator[T]:
289-
"""Async counterpart of ``SyncResource._list_all``. ``max_pages``
290-
semantics mirror the sync version (``None`` -> 1000 default).
291-
Raises ``KalshiError`` on repeated cursor; see sync docstring.
292-
"""
293-
page_cap = 1000 if max_pages is None else max_pages
289+
"""Async counterpart of :meth:`SyncResource._list_all`."""
294290
current_params = dict(params) if params else {}
295291
seen_cursors: set[str] = set()
296-
for _ in range(page_cap):
292+
pages_fetched = 0
293+
while max_pages is None or pages_fetched < max_pages:
297294
page = await self._list(
298295
path, model_cls, items_key,
299296
params=current_params, cursor_key=cursor_key,
300297
)
301298
for item in page.items:
302299
yield item
300+
pages_fetched += 1
303301
if not page.cursor:
304302
break
305303
if page.cursor in seen_cursors:

kalshi/resources/communications.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ def list_all_rfqs(
222222
market_ticker=market_ticker, subaccount=subaccount,
223223
status=status, creator_user_id=creator_user_id,
224224
)
225-
yield from self._list_all(
225+
return self._list_all(
226226
"/communications/rfqs", RFQ, "rfqs",
227227
params=params, max_pages=max_pages,
228228
)
@@ -418,7 +418,7 @@ async def list_rfqs(
418418
)
419419
return await self._list("/communications/rfqs", RFQ, "rfqs", params=params)
420420

421-
async def list_all_rfqs(
421+
def list_all_rfqs(
422422
self,
423423
*,
424424
limit: int | None = None,
@@ -429,18 +429,18 @@ async def list_all_rfqs(
429429
creator_user_id: str | None = None,
430430
max_pages: int | None = None,
431431
) -> AsyncIterator[RFQ]:
432+
# Plain `def` so _require_auth + _validate_max_pages run at call time.
432433
self._require_auth()
433434
_validate_max_pages(max_pages)
434435
params = _list_rfqs_params(
435436
cursor=None, limit=limit, event_ticker=event_ticker,
436437
market_ticker=market_ticker, subaccount=subaccount,
437438
status=status, creator_user_id=creator_user_id,
438439
)
439-
async for item in self._list_all(
440+
return self._list_all(
440441
"/communications/rfqs", RFQ, "rfqs",
441442
params=params, max_pages=max_pages,
442-
):
443-
yield item
443+
)
444444

445445
async def get_rfq(self, rfq_id: str) -> GetRFQResponse:
446446
self._require_auth()

kalshi/resources/incentive_programs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def list_all(
6262
) -> Iterator[IncentiveProgram]:
6363
_validate_max_pages(max_pages)
6464
params = _params(status=status, type=incentive_type, limit=limit)
65-
yield from self._list_all(
65+
return self._list_all(
6666
"/incentive_programs",
6767
IncentiveProgram,
6868
"incentive_programs",

kalshi/resources/milestones.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def list_all(
119119
related_event_ticker=related_event_ticker,
120120
cursor=None, min_updated_ts=min_updated_ts,
121121
)
122-
yield from self._list_all(
122+
return self._list_all(
123123
"/milestones", Milestone, "milestones",
124124
params=params, max_pages=max_pages,
125125
)

kalshi/resources/structured_targets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def list_all(
6666
competition=competition,
6767
page_size=page_size,
6868
)
69-
yield from self._list_all(
69+
return self._list_all(
7070
"/structured_targets",
7171
StructuredTarget,
7272
"structured_targets",

kalshi/resources/subaccounts.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def list_all_transfers(
160160
self._require_auth()
161161
_validate_max_pages(max_pages)
162162
params = _params(limit=limit)
163-
yield from self._list_all(
163+
return self._list_all(
164164
"/portfolio/subaccounts/transfers",
165165
SubaccountTransfer,
166166
"transfers",
@@ -254,23 +254,24 @@ async def list_transfers(
254254
params=params,
255255
)
256256

257-
async def list_all_transfers(
257+
def list_all_transfers(
258258
self,
259259
*,
260260
limit: int | None = None,
261261
max_pages: int | None = None,
262262
) -> AsyncIterator[SubaccountTransfer]:
263+
# Plain `def` (not `async def`) so _require_auth and _validate_max_pages
264+
# run at call time, not when the returned AsyncIterator is awaited.
263265
self._require_auth()
264266
_validate_max_pages(max_pages)
265267
params = _params(limit=limit)
266-
async for item in self._list_all(
268+
return self._list_all(
267269
"/portfolio/subaccounts/transfers",
268270
SubaccountTransfer,
269271
"transfers",
270272
params=params,
271273
max_pages=max_pages,
272-
):
273-
yield item
274+
)
274275

275276
@overload
276277
async def update_netting(

tests/_contract_support.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,26 @@ class Exclusion:
10611061
"kalshi.resources.incentive_programs.IncentiveProgramsResource.list_all",
10621062
"kalshi.resources.structured_targets.StructuredTargetsResource.list_all",
10631063
"kalshi.resources.multivariate.MultivariateCollectionsResource.list_all",
1064+
# Async counterparts — same kwarg, same client-only semantics.
1065+
"kalshi.resources.markets.AsyncMarketsResource.list_all",
1066+
"kalshi.resources.markets.AsyncMarketsResource.list_trades_all",
1067+
"kalshi.resources.milestones.AsyncMilestonesResource.list_all",
1068+
"kalshi.resources.events.AsyncEventsResource.list_all",
1069+
"kalshi.resources.events.AsyncEventsResource.list_all_multivariate",
1070+
"kalshi.resources.historical.AsyncHistoricalResource.markets_all",
1071+
"kalshi.resources.historical.AsyncHistoricalResource.fills_all",
1072+
"kalshi.resources.historical.AsyncHistoricalResource.orders_all",
1073+
"kalshi.resources.historical.AsyncHistoricalResource.trades_all",
1074+
"kalshi.resources.orders.AsyncOrdersResource.list_all",
1075+
"kalshi.resources.orders.AsyncOrdersResource.fills_all",
1076+
"kalshi.resources.communications.AsyncCommunicationsResource.list_all_rfqs",
1077+
"kalshi.resources.communications.AsyncCommunicationsResource.list_all_quotes",
1078+
"kalshi.resources.subaccounts.AsyncSubaccountsResource.list_all_transfers",
1079+
"kalshi.resources.portfolio.AsyncPortfolioResource.settlements_all",
1080+
"kalshi.resources.fcm.AsyncFcmResource.orders_all",
1081+
"kalshi.resources.incentive_programs.AsyncIncentiveProgramsResource.list_all",
1082+
"kalshi.resources.structured_targets.AsyncStructuredTargetsResource.list_all",
1083+
"kalshi.resources.multivariate.AsyncMultivariateCollectionsResource.list_all",
10641084
)
10651085
for _fqn in _MAX_PAGES_FQNS:
10661086
EXCLUSIONS[(_fqn, "max_pages")] = Exclusion(

tests/test_base_helpers.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ class TestSyncListAllCursorLoopDetection:
144144
def test_repeated_cursor_raises(
145145
self, test_auth: KalshiAuth, test_config: KalshiConfig
146146
) -> None:
147-
"""Server that returns the same cursor twice must bail fast, not retry 1000x."""
147+
"""Server that returns the same cursor twice must bail fast on the 2nd request."""
148148
route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock(
149149
return_value=httpx.Response(
150150
200, json={"items": [{"id": "x"}], "cursor": "loop"}
@@ -156,8 +156,7 @@ def test_repeated_cursor_raises(
156156
list(resource._list_all("/things", _Item, "items"))
157157

158158
# First call (no cursor) fetches cursor="loop". Second call (cursor=loop) returns
159-
# cursor="loop" again → loop detected before a third request. Total: 2 requests,
160-
# not 1000.
159+
# cursor="loop" again → loop detected before a third request.
161160
assert route.call_count == 2
162161

163162
@respx.mock
@@ -366,3 +365,74 @@ def test_zero_rejected(self) -> None:
366365
def test_negative_rejected(self) -> None:
367366
with pytest.raises(ValueError, match=r"max_pages must be positive"):
368367
_validate_max_pages(-3)
368+
369+
370+
class TestMaxPagesEagerValidation:
371+
"""Regression: ``_validate_max_pages`` must fire at call time, not on first iteration.
372+
373+
Methods that were previously written with ``yield from``/``async for ... yield``
374+
silently became generators — their body didn't execute until the caller advanced
375+
the iterator, so `max_pages=0` got deferred. All `*_all()` methods must now use
376+
`return self._list_all(...)` so the validator runs eagerly.
377+
"""
378+
379+
def test_sync_milestones_list_all_validates_eagerly(
380+
self, test_auth: KalshiAuth, test_config: KalshiConfig,
381+
) -> None:
382+
from kalshi.resources.milestones import MilestonesResource
383+
resource = MilestonesResource(SyncTransport(test_auth, test_config))
384+
with pytest.raises(ValueError, match=r"max_pages must be positive"):
385+
resource.list_all(limit=10, max_pages=0)
386+
387+
def test_sync_subaccounts_list_all_transfers_validates_eagerly(
388+
self, test_auth: KalshiAuth, test_config: KalshiConfig,
389+
) -> None:
390+
from kalshi.resources.subaccounts import SubaccountsResource
391+
resource = SubaccountsResource(SyncTransport(test_auth, test_config))
392+
with pytest.raises(ValueError, match=r"max_pages must be positive"):
393+
resource.list_all_transfers(max_pages=0)
394+
395+
@pytest.mark.asyncio
396+
async def test_async_subaccounts_list_all_transfers_validates_eagerly(
397+
self, test_auth: KalshiAuth, test_config: KalshiConfig,
398+
) -> None:
399+
from kalshi.resources.subaccounts import AsyncSubaccountsResource
400+
resource = AsyncSubaccountsResource(AsyncTransport(test_auth, test_config))
401+
with pytest.raises(ValueError, match=r"max_pages must be positive"):
402+
resource.list_all_transfers(max_pages=0)
403+
404+
@pytest.mark.asyncio
405+
async def test_async_communications_list_all_rfqs_validates_eagerly(
406+
self, test_auth: KalshiAuth, test_config: KalshiConfig,
407+
) -> None:
408+
from kalshi.resources.communications import AsyncCommunicationsResource
409+
resource = AsyncCommunicationsResource(AsyncTransport(test_auth, test_config))
410+
with pytest.raises(ValueError, match=r"max_pages must be positive"):
411+
resource.list_all_rfqs(max_pages=0)
412+
413+
414+
class TestMaxPagesNoneIsUnbounded:
415+
"""``max_pages=None`` must iterate until the server returns no cursor.
416+
417+
The 1000-page default was removed: cursor-repeat guard is the real safety net.
418+
"""
419+
420+
@respx.mock
421+
def test_sync_iterates_past_1000_pages(
422+
self, test_auth: KalshiAuth, test_config: KalshiConfig,
423+
) -> None:
424+
# Page N returns cursor str(N+1) for the first 1100 pages, then empty.
425+
call_counter = {"n": 0}
426+
427+
def responder(request: httpx.Request) -> httpx.Response:
428+
call_counter["n"] += 1
429+
n = call_counter["n"]
430+
cursor = str(n) if n < 1100 else ""
431+
return httpx.Response(
432+
200, json={"items": [{"id": f"i{n}"}], "cursor": cursor},
433+
)
434+
435+
respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responder)
436+
resource = SyncResource(SyncTransport(test_auth, test_config))
437+
items = list(resource._list_all("/things", _Item, "items"))
438+
assert len(items) == 1100, f"Expected 1100 items, got {len(items)} (cap leaked?)"

0 commit comments

Comments
 (0)