Fetch every page of a cursor-paginated API
Problem statement
An inventory service lists hosts with cursor pagination: each response holds one page of items and a next_cursor. You pass that cursor back to get the next page, and a null (or empty) cursor means you have reached the last page. A report script needs every host, not just the first page.
Write fetch_all(client, path) that returns all items across all pages, in order. The client has one method, client.get(path, cursor=None), which returns the parsed JSON as a dict. For the exercise, use a fake client that returns these three pages, so the code runs offline:
Mock API responses
GET /hosts -> {"items": [{"name": "web-1", "env": "prod"}, {"name": "web-2", "env": "prod"}], "next_cursor": "c2"}GET /hosts?cursor=c2 -> {"items": [{"name": "db-1", "env": "prod"}, {"name": "ci-1", "env": "dev"}], "next_cursor": "c3"}GET /hosts?cursor=c3 -> {"items": [{"name": "cache-1", "env": "staging"}], "next_cursor": null}Then print how many hosts were fetched and in how many calls, one line per host, and a count per environment sorted by name.
Your function must also survive a buggy server. If a cursor comes back that you have already used, the server is looping and a naive while loop would run forever; raise RuntimeError instead. Put a hard cap on the number of pages as a second safety net, and raise ValueError if a page has no items list.
Examples
Example 1
Input: The three mock pages above, then a second fake client that always returns `next_cursor: "c2"`.
Output: fetched 5 hosts in 3 calls
web-1 prod
web-2 prod
db-1 prod
ci-1 dev
cache-1 staging
by env: dev=1, prod=3, staging=1
error: cursor 'c2' repeated, server is looping
Explanation: The looping client returns c2 on the first call and c2 again on the second, so the second sighting raises instead of spinning forever.
Hints
Approach
Use one loop for every request, starting from cursor = None, so there is no special first call.
- Request the page and check that
itemsis a list; raiseValueErrorwith the page in the message if not. - Extend the result list, then read
next_cursorwith.get().if not cursortreats bothNoneand""as the end. - Before using a cursor, check it against a
setof cursors already seen. A repeat raisesRuntimeError, which turns an infinite loop into a clear error. - The
for _ in range(max_pages)loop is the backstop: even a server that invents a new cursor every time cannot keep the script running forever.
n is the total number of items. The fake client counts its calls, which lets the script print 3 calls and lets a test check that no extra request was made after the last page. The per-environment counts come from Counter, sorted so the output is stable.
O(n)Space O(n)from collections import Counter class FakeInventoryAPI: # Stands in for GET /hosts?cursor=... so the code runs offline. PAGES = { None: {"items": [{"name": "web-1", "env": "prod"}, {"name": "web-2", "env": "prod"}], "next_cursor": "c2"}, "c2": {"items": [{"name": "db-1", "env": "prod"}, {"name": "ci-1", "env": "dev"}], "next_cursor": "c3"}, "c3": {"items": [{"name": "cache-1", "env": "staging"}], "next_cursor": None}, } def __init__(self): self.calls = 0 def get(self, path, cursor=None): self.calls += 1 return self.PAGES[cursor] class LoopingAPI: # A buggy server that keeps handing back the same cursor. def get(self, path, cursor=None): return {"items": [{"name": "x", "env": "dev"}], "next_cursor": "c2"} def fetch_all(client, path, max_pages=1000): items = [] cursor = None seen = set() for _ in range(max_pages): page = client.get(path, cursor=cursor) batch = page.get("items") if not isinstance(batch, list): raise ValueError(f"page has no 'items' list: {page!r}") items.extend(batch) cursor = page.get("next_cursor") if not cursor: # None or "" both mean "last page" return items if cursor in seen: raise RuntimeError(f"cursor {cursor!r} repeated, server is looping") seen.add(cursor) raise RuntimeError(f"stopped after {max_pages} pages") api = FakeInventoryAPI()hosts = fetch_all(api, "/hosts")print(f"fetched {len(hosts)} hosts in {api.calls} calls")for h in hosts: print(f" {h['name']:<8} {h['env']}")by_env = Counter(h["env"] for h in hosts)print("by env:", ", ".join(f"{env}={n}" for env, n in sorted(by_env.items()))) try: fetch_all(LoopingAPI(), "/hosts")except RuntimeError as e: print("error:", e)Follow-up questions
- Add retries with backoff around each page request, so one failed page does not throw away the pages already fetched.
- The API also returns a
Link: <...>; rel="next"header instead of a cursor field. How do you follow that? - Turn
fetch_allinto a generator and explain what changes for the caller.
Frequently asked questions
Almost every cloud and SaaS API that lists things (instances, DNS records, users, pipeline runs) paginates. A script that reads only the first page silently reports on part of the fleet, and that kind of bug usually goes unnoticed until an audit misses something.
Offset pagination asks for ?page=3 or ?offset=200. If items are added or removed while you page through, you can skip or repeat items. A cursor is an opaque token the server uses to continue exactly where the last page ended, so it is stable under changes. Your loop looks the same for both; only the value you pass back differs.
A list is fine for thousands of items. For very large result sets, make it a generator (yield from batch) so the caller can start processing and memory stays flat. The loop and the safety checks do not change.