Skip to content

fix(pagination): continue past an empty page that carries a cursor - #1943

Closed
feiiiiii5 wants to merge 1 commit into
anthropics:mainfrom
feiiiiii5:fix/pagination-empty-page-cursor
Closed

feiiiiii5 wants to merge 1 commit into
anthropics:mainfrom
feiiiiii5:fix/pagination-empty-page-cursor

Conversation

@feiiiiii5

@feiiiiii5 feiiiiii5 commented Sep 20, 2026 •

Copy link
Copy Markdown

Problem

Auto-pagination stops at an empty page even when the response still carries a cursor. A caller doing for f in client.files.list(): ... receives only the items from the first page.

Reproduced against a mock transport that returns {"data": [], "next_page": "tok-3"} for GET /v1/files?page=tok-2: the client issues tok-2, gets the empty page with a further cursor, and stops. ['file_1'] where ['file_1', 'file_3'] is expected. The same BasePage backs skills.list() and the vault, environment, memory-store and session endpoints.

Root cause

src/anthropic/_base_client.py:211 dropped the cursor check behind an emptiness check:

items = self._get_page_items()
if not items:
    return False
return self.next_page_info() is not None

For the cursor-based page types (PageCursor, TokenPage, BidirectionalPageCursor) the cursor is independent of the rows returned with it, so those two statements disagree, and iter_pages() (:285-291) gates only on has_next_page().

Change

has_next_page() now defers to the cursor:

return self.next_page_info() is not None

Behaviour that is already cursor-driven is unchanged:

Tests and results

tests/test_pagination.py gains a case per cursor page type: an empty first-or-middle page that still returns a cursor must be followed.

  • Base (0af01906, test file present, source unpatched): DEFER_PYDANTIC_BUILD=false uv run --no-sync pytest tests/test_pagination.py -q -p no:randomly -> 3 failed, exit 1. The log shows the tok-2 response carrying next_page: tok-3 with no following request.
  • Head (aac10d6): same command -> 3 passed, exit 0 (re-run after a comment reword, 3 passed in 2.84 s).
  • Offline set around it: 6 files -> 323 passed, 2 skipped, exit 0.
  • Wider tree excluding tests/api_resources/: 1 failed, 2101 passed, 11 skipped, 1 xfailed. The one failure, test_agent_toolset.py::test_text_io_is_utf8_under_ascii_locale, reproduces identically at base; it is a non-ASCII path artifact in this environment, not a change in behaviour.
  • ruff check passes, ruff format --check clean, pyright --project . reports 0 errors. mypy reports 42 errors, all in the untouched tests/test_client.py, and the same 42 when that file is checked alone, so this change adds none.
  • tests/api_resources/ needs the repository's npm mock server and was not run here.

Scope and open questions

Two points a maintainer may want to decide differently:

  1. The edit is in the shared BasePage.has_next_page(). The alternative is to override it per class in pagination.py, which keeps the base class as-is but repeats the same three lines for each cursor page.
  2. With this change, a server that returns an empty page while echoing the same cursor advances forever. That property already holds for non-empty pages, since nothing in iter_pages() compares successive cursors, so this change does not introduce it; it does widen when it can be reached.

Note on authorship

Produced by an AI agent under this account owner's direction; the commands and counts above are its own local runs. No human has reviewed the diff yet.

@feiiiiii5
feiiiiii5 requested a review from a team as a code owner September 20, 2026 10:28
…rsor

`BasePage.has_next_page()` returned False whenever the page held zero rows, so every
cursor-based page (`PageCursor`, `TokenPage`, `BidirectionalPageCursor`, used by
`files.list()`, `skills.list()`, the vault/environment/memory/session endpoints) ended
auto-pagination on an empty page even though `next_page_info()` still returned a cursor,
silently dropping all remaining items. The opaque cursor is independent of the rows that
came back with it, so only `next_page_info()` decides whether there is a way forward.
@feiiiiii5

Copy link
Copy Markdown
Author

Checking in on this one — CI has been green since aac10d65 and it has had no review for about two weeks.

The two open decisions are both about where the fix belongs, so a one-line steer is all I need:

  1. I changed the shared BasePage.has_next_page() in _base_client.py. The narrower option is to override it per class in pagination.py — same three lines, repeated for each cursor page type, base class untouched. I went with the base because the empty-page rule genuinely differs between id-cursor and token-cursor pages and that is exactly the knowledge the base class already has.

  2. Related: a server that returns an empty page while echoing the same cursor will now loop forever. That property already existed for non-empty pages — nothing in iter_pages() compares successive cursors — so this does not introduce it, but it does widen the set of responses that can reach it. If you would rather not have it here at all, adding a "stop when the cursor repeats" guard to iter_pages() would close it for both cases and I am happy to do that instead.

Happy either way, in one commit.

@feiiiiii5

Copy link
Copy Markdown
Author

Hi @anthropic-sdk-python maintainers — #1943 has been open since 27 September with CI green, so this is the one nudge I planned.

Recap: auto-pagination stops at an empty page even when the response still carries a cursor, so for f in client.files.list(): ... silently returns only the first page's items. has_next_page() gated the cursor check behind an emptiness check, but the cursor-based page types (PageCursor, TokenPage, BidirectionalPageCursor) treat the cursor as independent of the rows that came with it — the same BasePage backs skills.list() and the vault, environment, memory-store and session endpoints. The fix defers to the cursor.

The question I would most like settled, because it is the one design decision I could not make from the code: with the fix, a server that returns an empty page plus a cursor is now followed to the next page, which means a caller iterating a cursor that never terminates can loop — iter_pages() already guards against an unbounded walk with a page cap, but only by page count. Should this also carry an "empty page with a cursor" cap (say, stop after N consecutive empty pages) so a misbehaving server cannot make a caller spin, or is the existing cap enough for you?

@tomer-ant

Copy link
Copy Markdown
Contributor

Thanks. This is fixed on main in c027a26, so we're closing this PR.

@tomer-ant tomer-ant closed this Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants