{"record":{"id":"2dd2fa3e2fd9f814","repo":"666ghj/MiroFish","slug":"github-graphql-repeated-a-page-cursor","errorCode":null,"errorMessage":"GitHub GraphQL repeated a page cursor","messagePattern":"GitHub GraphQL repeated a page cursor","errorType":"exception","errorClass":"StarHistoryError","httpStatus":null,"severity":"error","filePath":"scripts/star_history.py","lineNumber":596,"sourceCode":"    while True:\n        page = github.fetch_stargazer_page(after)\n        page_number += 1\n        if initial_total is None:\n            initial_total = page.total_count\n            pages_required = math.ceil(initial_total / PAGE_SIZE)\n            remaining_requests = max(0, pages_required - 1)\n            if page.rate_remaining < remaining_requests + RATE_LIMIT_RESERVE:\n                raise StarHistoryError(\"insufficient GitHub GraphQL rate limit for backfill\")\n        for edge in page.edges:\n            if edge.cursor in seen_edge_cursors:\n                raise StarHistoryError(\"GitHub GraphQL repeated an edge cursor\")\n            seen_edge_cursors.add(edge.cursor)\n            if edge.starred_at.date() < normalized_now.date():\n                daily_increments[edge.starred_at.date()] += 1\n\n        if page.end_cursor is not None:\n            if page.end_cursor in seen_page_cursors:\n                raise StarHistoryError(\"GitHub GraphQL repeated a page cursor\")\n            seen_page_cursors.add(page.end_cursor)\n        if not page.has_next_page:\n            break\n        if page.end_cursor is None:\n            raise StarHistoryError(\"GitHub GraphQL omitted the next page cursor\")\n        after = page.end_cursor\n        if page_number > 10_000:\n            raise StarHistoryError(\"GitHub GraphQL exceeded the page safety limit\")\n\n    if initial_total is None:\n        raise StarHistoryError(\"GitHub GraphQL returned no pages\")\n    if len(seen_edge_cursors) != initial_total:\n        raise StarHistoryError(\"stargazer list changed or was incomplete during backfill\")\n\n    running = 0\n    daily: list[dict[str, Any]] = []\n    for point_day in sorted(daily_increments):\n        running += daily_increments[point_day]","sourceCodeStart":578,"sourceCodeEnd":614,"githubUrl":"https://github.com/666ghj/MiroFish/blob/b5b53acc57189a4a42e44a23e149dc655c98fe82/scripts/star_history.py#L578-L614","documentation":"Raised in the backfill pagination loop at scripts/star_history.py:596. After each page the script records `page.end_cursor` in `seen_page_cursors`; if a subsequent page's `endCursor` value was already seen, the traversal is going in circles and the loop aborts instead of fetching the same pages forever. This complements the per-edge cursor check and the `page_number > 10_000` safety limit.","triggerScenarios":"The loop does `after = page.end_cursor` each iteration; if GitHub's `pageInfo.endCursor` for a `hasNextPage: true` page equals an `endCursor` already returned earlier, `page.end_cursor in seen_page_cursors` is true and the error fires. Happens when the stargazer list shrinks (stars removed) mid-traversal so GitHub re-issues a cursor, or when a gateway/test double returns a constant endCursor while claiming `has_next_page`.","commonSituations":"Long backfills over thousands of pages on repos with concurrent un-starring; GitHub GraphQL cursor instability under heavy mutation; broken fake gateways in the test suite that always return the same endCursor with hasNextPage=true (which would otherwise infinite-loop).","solutions":["Re-run the backfill later — a shrinking stargazer list mid-traversal is usually transient and a clean run succeeds when churn settles.","Check the run duration: backfills that take long enough for the list to churn are the usual cause; prefer running when the repo is quiet.","If writing a test gateway, make `end_cursor` advance (e.g. `f\"page-{n}\"`) and set `has_next_page=False` on the final page.","If it reproduces consistently, dump the failing page's pageInfo via `gh api graphql` and verify whether endCursor actually repeats; that would be an upstream GitHub defect worth reporting."],"exampleFix":"class FakeGateway:\n    def fetch_stargazer_page(self, after):\n        # before: end_cursor=\"same\" on every page -> 'repeated a page cursor'\n        # after: unique, advancing end cursor per page\n        idx = 0 if after is None else int(after.split(\"-\")[1]) + 1\n        return StargazerPage(\n            total_count=300,\n            edges=make_edges(idx * 100, 100),\n            has_next_page=idx < 2,\n            end_cursor=f\"page-{idx}\" if idx < 2 else None,\n            rate_remaining=5000,\n        )","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"attempts = 0\nwhile True:\n    try:\n        state = run_backfill(gateway, clock)\n        break\n    except StarHistoryError as exc:\n        attempts += 1\n        if attempts >= 3 or \"repeated a page cursor\" not in str(exc):\n            raise\n        logger.warning(\"pagination loop detected (attempt %d), restarting\", attempts)","preventionTips":["Keep backfill runs short: the longer the pagination, the more likely concurrent un-stars re-issue a cursor.","Never implement a custom gateway that returns a constant endCursor with has_next_page=True.","Watch rate_remaining between retries — each restart re-consumes pages_required requests; respect RATE_LIMIT_RESERVE=20.","Preserve both the page-cursor and edge-cursor seen-sets if you fork the loop; they are the loop-breakers."],"tags":["github-api","graphql","pagination","infinite-loop-guard","backfill"],"backgroundTag":null,"analyzedSha":"b5b53acc57189a4a42e44a23e149dc655c98fe82","analyzedAt":"2026-08-14T22:29:33.146Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}