Skip to content

Diagnostic GitHub API

only the first page is read because the Link header is ignored

The request returned 200. The JSON is a well-formed array of pull requests. Your audit read it, counted 30, and reported that the repository is tidy. There are 340 open pull requests. Nothing failed, nothing was logged, and the number you are now acting on is wrong by an order of magnitude — the answer was complete for one page and the rest was advertised in a header nobody read.

Read-only token Python and Node.js Tests included
A rack of servers
Photo by Yuriy Vertikov on Unsplash
The short answer

Call the list endpoint your integration uses with per_page=1 and read the Link response header. It looks like <...&page=2>; rel="next", <...&page=340>; rel="last", and at per_page=1 the page number in rel="last" is the exact item count. Compare it against whatever your integration reports.

Where rel="last" is absent, follow rel="next" until it disappears. Terminating on a missing rel="last" is the same bug in a different costume.

The problem in plain words

Every other failure in this section announces itself with a status code. This one returns 200 and valid JSON, and the JSON is not even wrong: the API was asked for a page and it returned that page, correctly. The mistake is entirely in the reading, which is why it survives code review, passes tests written against a fixture of five items, and ships.

It is also the failure that scales in the wrong direction. On a small repository the first page is the whole list, so the code appears to work for months. The bug activates on the thirty-first item, in whichever repository grows past it first, usually the busiest and most important one. A stale-branch report that says "no stale branches" for the monorepo is not reporting health; it is reporting that the monorepo has more than 30 branches.

Client asks forpullsno per_page setGitHub returns30200 and valid JSONLink headerignoredrel=next unreadReport says 30open340 existNobody sees anerrornothing to log
Every step returns 200. The only statement about completeness is in a header, and the header is the part that was thrown away.

Why it happens

The size of the collection is in a header, not in the body. There is no total_count on REST list endpoints and no has_more flag. The only statement about completeness is the Link header, and a client that deserialises the body and discards the response object never sees it. Most convenience wrappers around fetch and requests return parsed JSON and throw the headers away by default.

Thirty is a plausible number. If the default page were 3 items, someone would notice on day one. Thirty open pull requests, thirty branches, thirty workflow runs — every one of those is a number a human will accept without checking, which is what makes the default page size a trap rather than an inconvenience.

Hand-built page URLs drift. Clients that do paginate often construct ?page=N themselves rather than following the URL GitHub returned. That works until an endpoint moves to cursor-based paging, at which point the loop keeps returning page 1 forever, or terminates immediately, and again nothing errors.

The API cannot see your client, so nothing on GitHub's side will ever complain. This is the honest limit of every check on this page. No endpoint reports whether you followed rel="next"; there is no server-side record of your parsing. What a read-only script can prove is that the trap is set: that this endpoint, for this repository, right now, has pages beyond the first and a true count that differs from 30. Whether your code walks them is a question for your code.

The fix, as a flow

The script probes at one item per page, because at that page size the page number in rel="last" is the exact size of the collection. It reads the header rather than the body, which is the same thing the broken client failed to do.

Probe at per_page=1read the Link headerNo rel=next at allone page really is the whole listrel=last says 340and your report says 30rel=next, no rel=lasttruncated, and size unknownCounts already agreethe loop is following next
A rel=next with no rel=last is still a truncated list, so it gets its own state rather than being rounded to either neighbour.

How to fix it

Probe the endpoint with per_page=1

One request, one item of transfer, and the rel="last" page number comes back as the exact size of the collection. This is the cheapest true count the REST API offers, and it costs one unit of the hourly 5,000.

Read the Link header, not the body

Parse it by matching <url>; rel="name" rather than splitting on commas — pagination URLs can contain commas of their own (labels=bug,ci is the everyday case) and a naive split(",") produces two broken links out of one good one.

Compare the true count against what your integration reports

If your dashboard says 30, 60 or 100 and the header says 340, you have found it. Round numbers that are exact multiples of a page size are the signature; a client that paginates correctly almost never lands on one.

Follow rel="next" until it is absent

That is the whole termination condition. Not a page count, not rel="last", not an empty array — the absence of rel="next". Use octokit.paginate(), PyGithub's PaginatedList, or gh api --paginate, all three of which implement exactly that.

Set per_page=100 while you are in there

It costs nothing and cuts the request count by roughly 70%, but do it after the loop is correct. A non-paginating client with per_page=100 is not fixed; it now reports 100 instead of 30, which is a larger and more convincing lie.

How to check it worked

Re-run the script against the repository that was under-reporting. Every probed endpoint should either be a single page or be one you now walk in full.

GITHUB_TOKEN=... python3 github_link_header_audit.py --repo octocat/hello-world
# 5 endpoint(s) probed, 2 with pages beyond the first; x-ratelimit-remaining 4993

The full code

The script probes a handful of list endpoints at per_page=1 and reads the header rather than the body — five GETs, no writes, a read-only token. The parsing and the judgement are two pure functions, because the interesting bugs here are in exactly those two places: a header split on the wrong character, and a loop that stops on the wrong condition.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 GitHub API fixes, free and open source.
github_link_header_audit.py
"""Report GitHub list endpoints that advertise pages your client may not read.

Read only. GET requests and nothing else: a token with read access to the
repository is enough, and that is what you should give it. The repair is printed,
never performed.

What this can and cannot see: the API has no idea whether your client follows
rel="next". It can only say whether there is a next page there to be missed, and
how many items are on the far side of it. That is the trap, not the fall.
"""
import argparse
import logging
import os
import re
import sys
from urllib.parse import parse_qs, urlparse

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_link_header_audit")

API = "https://api.github.com"

# Anchored on the angle brackets rather than split on ",". A pagination URL can
# contain a comma of its own -- labels=bug,ci is the everyday case -- and
# splitting the header on commas turns one good link into two broken ones.
LINK = re.compile(r'<([^>]+)>\s*;\s*rel="([^"]+)"')

PROBES = [
    ("pulls", {"state": "open"}),
    ("issues", {"state": "all"}),
    ("branches", {}),
    ("tags", {}),
    ("contributors", {}),
]


def parse_link(header):
    """Parse a Link header into {rel: url}. Pure, so it is tested offline."""
    if not header:
        return {}
    return {rel: url for url, rel in LINK.findall(header)}


def page_number(url):
    """Read the page query parameter out of a pagination URL, or None."""
    if not url:
        return None
    values = parse_qs(urlparse(url).query).get("page") or []
    try:
        return int(values[0])
    except (IndexError, TypeError, ValueError):
        return None


def verdict(links, received, per_page=1):
    """Classify what one list response says about its own completeness.

    Pure, so the rules are visible rather than buried in a request loop.
    Returns (state, detail).

    The states are deliberately three and not two. "more-pages-unsized" is the
    case where rel="next" exists and rel="last" does not: the list is still
    truncated, and a loop that terminates on the missing rel="last" is the same
    bug this note is about.
    """
    if "next" not in links:
        return ("single-page",
                '%d item(s) and no rel="next". One request really is the whole '
                "list here." % received)

    last = page_number(links.get("last"))
    if last is None:
        return ("more-pages-unsized",
                'rel="next" is present and rel="last" is not, so the total is only '
                "knowable by walking it. Terminate on the absence of "
                'rel="next", never on the absence of rel="last".')

    if per_page == 1:
        return ("more-pages",
                "%d item(s) in total. A client that reads the first page and stops "
                "reports %d." % (last, received))

    return ("more-pages",
            "%d page(s) at per_page=%d, so %d to %d item(s) in total. A client "
            "that reads the first page and stops reports %d."
            % (last, per_page, (last - 1) * per_page + 1, last * per_page, received))


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=30)
    if r.status_code == 401:
        raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed or "
                         "revoked")
    if r.status_code == 403 and "rate limit" in r.text.lower():
        raise SystemExit("403 rate limited. GET /rate_limit reports the reset time "
                         "and does not itself consume quota")
    if r.status_code == 404:
        raise SystemExit("404 on %s: the repository does not exist, or this token "
                         "cannot see it -- GitHub returns 404 rather than 403 for "
                         "resources you may not know about" % path)
    r.raise_for_status()
    return r


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--repo", required=True, help="owner/name")
    ap.add_argument("--path", action="append",
                    help="probe this API path instead of the defaults, e.g. "
                         "/repos/o/n/releases. Repeatable.")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (a read-only token is enough)")
        return 2

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        # GitHub rejects requests with no User-Agent outright.
        "User-Agent": "github-link-header-audit",
    })

    if args.path:
        probes = [(p, {}) for p in args.path]
    else:
        probes = [("/repos/%s/%s" % (args.repo, name), extra)
                  for name, extra in PROBES]

    truncatable = 0
    remaining = "?"
    for path, extra in probes:
        # per_page=1 makes the rel="last" page number the exact item count, for
        # one request and one item of transfer.
        r = get(session, path, per_page=1, **extra)
        remaining = r.headers.get("x-ratelimit-remaining", "?")
        body = r.json()
        received = len(body) if isinstance(body, list) else 0
        state, detail = verdict(parse_link(r.headers.get("Link")), received, 1)

        line = "%-18s %s  %s" % (state, path, detail)
        if state == "single-page":
            log.info(line)
            continue
        truncatable += 1
        log.warning(line)
        log.warning('  repair: follow rel="next" until it is absent -- '
                    "octokit.paginate() in Octokit, the PaginatedList in PyGithub, "
                    "gh api --paginate on the command line. Never build page URLs "
                    "by hand.")

    log.info("%d endpoint(s) probed, %d with pages beyond the first; "
             "x-ratelimit-remaining %s", len(probes), truncatable, remaining)
    return 1 if truncatable else 0


if __name__ == "__main__":
    sys.exit(main())
github-link-header-audit.mjs
/**
 * Report GitHub list endpoints that advertise pages your client may not read.
 *
 * Read only. GET requests and nothing else: a token with read access to the
 * repository is enough. The repair is printed, never performed.
 *
 * The API cannot see whether your client follows rel="next". It can only say
 * whether there is a next page there to be missed.
 */
const API = 'https://api.github.com';

// Anchored on the angle brackets rather than split on ','. A pagination URL can
// contain a comma of its own (labels=bug,ci) and splitting the header on commas
// turns one good link into two broken ones.
const LINK = /<([^>]+)>\s*;\s*rel="([^"]+)"/g;

const PROBES = [
  ['pulls', { state: 'open' }],
  ['issues', { state: 'all' }],
  ['branches', {}],
  ['tags', {}],
  ['contributors', {}],
];

/** Parse a Link header into a Map of rel to url. Pure, so it is tested offline. */
export function parseLink(header) {
  const out = new Map();
  if (!header) return out;
  for (const m of String(header).matchAll(LINK)) out.set(m[2], m[1]);
  return out;
}

/** Read the page query parameter out of a pagination URL, or null. */
export function pageNumber(url) {
  if (!url) return null;
  let value;
  try {
    value = new URL(url, API).searchParams.get('page');
  } catch {
    return null;
  }
  const n = Number(value);
  return value !== null && Number.isInteger(n) ? n : null;
}

/**
 * Classify what one list response says about its own completeness. Pure.
 * Returns [state, detail].
 *
 * Three states, not two: a rel="next" with no rel="last" is still a truncated
 * list, and a loop that stops there has the same bug in a different costume.
 */
export function verdict(links, received, perPage = 1) {
  if (!links.has('next')) {
    return ['single-page',
      `${received} item(s) and no rel="next". One request really is the whole ` +
      'list here.'];
  }

  const last = pageNumber(links.get('last'));
  if (last === null) {
    return ['more-pages-unsized',
      'rel="next" is present and rel="last" is not, so the total is only ' +
      'knowable by walking it. Terminate on the absence of rel="next", never ' +
      'on the absence of rel="last".'];
  }

  if (perPage === 1) {
    return ['more-pages',
      `${last} item(s) in total. A client that reads the first page and stops ` +
      `reports ${received}.`];
  }

  return ['more-pages',
    `${last} page(s) at per_page=${perPage}, so ${(last - 1) * perPage + 1} to ` +
    `${last * perPage} item(s) in total. A client that reads the first page and ` +
    `stops reports ${received}.`];
}

function arg(name, fallback) {
  const i = process.argv.indexOf(`--${name}`);
  return i === -1 ? fallback : process.argv[i + 1];
}

async function get(token, path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': 'github-link-header-audit',
    },
  });
  if (res.status === 401) {
    throw new Error('401 from GitHub: GITHUB_TOKEN is missing, malformed or revoked');
  }
  if (res.status === 403) {
    throw new Error('403 from GitHub. If this is a rate limit, GET /rate_limit ' +
                    'reports the reset and does not itself consume quota');
  }
  if (res.status === 404) {
    throw new Error(`404 on ${path}: the repository does not exist, or this token ` +
                    'cannot see it');
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res;
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    console.error('set GITHUB_TOKEN (a read-only token is enough)');
    process.exitCode = 2;
    return;
  }
  const repo = arg('repo');
  if (!repo) {
    console.error('usage: node github-link-header-audit.mjs --repo owner/name');
    process.exitCode = 2;
    return;
  }

  const probes = PROBES.map(([name, extra]) => [`/repos/${repo}/${name}`, extra]);

  let truncatable = 0;
  let remaining = '?';
  for (const [path, extra] of probes) {
    const res = await get(token, path, { per_page: 1, ...extra });
    remaining = res.headers.get('x-ratelimit-remaining') ?? '?';
    const body = await res.json();
    const received = Array.isArray(body) ? body.length : 0;
    const [state, detail] = verdict(parseLink(res.headers.get('link')), received, 1);

    const line = `${state.padEnd(18)} ${path}  ${detail}`;
    if (state === 'single-page') { console.log(line); continue; }
    truncatable += 1;
    console.warn(line);
    console.warn('  repair: follow rel="next" until it is absent -- ' +
                 'octokit.paginate() in Octokit, the PaginatedList in PyGithub, ' +
                 'gh api --paginate on the command line. Never build page URLs ' +
                 'by hand.');
  }

  console.log(`${probes.length} endpoint(s) probed, ${truncatable} with pages ` +
              `beyond the first; x-ratelimit-remaining ${remaining}`);
  process.exitCode = truncatable ? 1 : 0;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing token and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

Two rules are worth pinning. A Link header carrying a comma inside a URL must still parse into two links and not four, because that is a parser that looks correct on every repository until someone filters by two labels. And a rel="next" with no rel="last" must not collapse into either neighbour: it is not a complete list, and it is not a sized one either.

test_github_link_header_audit.py
from github_link_header_audit import page_number, parse_link, verdict

FULL = ('<https://api.github.com/repositories/1/pulls?per_page=1&page=2>; rel="next", '
        '<https://api.github.com/repositories/1/pulls?per_page=1&page=340>; rel="last"')


def test_link_header_parses_both_relations():
    links = parse_link(FULL)
    assert set(links) == {"next", "last"}
    assert page_number(links["last"]) == 340


def test_a_comma_inside_a_url_does_not_become_a_second_link():
    # labels=bug,ci is ordinary. Splitting the header on "," makes four broken
    # entries out of two good ones and the walk then terminates on page one.
    header = ('<https://api.github.com/repos/o/n/issues?labels=bug,ci&page=2>; rel="next", '
              '<https://api.github.com/repos/o/n/issues?labels=bug,ci&page=9>; rel="last"')
    links = parse_link(header)
    assert set(links) == {"next", "last"}
    assert links["next"].endswith("labels=bug,ci&page=2")


def test_no_link_header_is_a_single_page():
    state, detail = verdict(parse_link(None), 7, 1)
    assert state == "single-page"
    assert "7 item(s)" in detail


def test_rel_last_at_per_page_one_is_the_exact_count():
    state, detail = verdict(parse_link(FULL), 1, 1)
    assert state == "more-pages"
    assert "340 item(s)" in detail


def test_next_without_last_is_its_own_state():
    header = '<https://api.github.com/repos/o/n/branches?page=2>; rel="next"'
    state, detail = verdict(parse_link(header), 1, 1)
    assert state == "more-pages-unsized"
    assert 'rel="last"' in detail


def test_page_number_is_none_when_there_is_no_page_parameter():
    assert page_number("https://api.github.com/repos/o/n/pulls?per_page=100") is None
    assert page_number(None) is None
github-link-header-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { pageNumber, parseLink, verdict } from './github-link-header-audit.mjs';

const FULL =
  '<https://api.github.com/repositories/1/pulls?per_page=1&page=2>; rel="next", ' +
  '<https://api.github.com/repositories/1/pulls?per_page=1&page=340>; rel="last"';

test('link header parses both relations', () => {
  const links = parseLink(FULL);
  assert.deepEqual([...links.keys()].sort(), ['last', 'next']);
  assert.equal(pageNumber(links.get('last')), 340);
});

test('a comma inside a url does not become a second link', () => {
  const header =
    '<https://api.github.com/repos/o/n/issues?labels=bug,ci&page=2>; rel="next", ' +
    '<https://api.github.com/repos/o/n/issues?labels=bug,ci&page=9>; rel="last"';
  const links = parseLink(header);
  assert.deepEqual([...links.keys()].sort(), ['last', 'next']);
  assert.match(links.get('next'), /labels=bug,ci&page=2$/);
});

test('no link header is a single page', () => {
  const [state, detail] = verdict(parseLink(null), 7, 1);
  assert.equal(state, 'single-page');
  assert.match(detail, /7 item\(s\)/);
});

test('rel=last at per_page=1 is the exact count', () => {
  const [state, detail] = verdict(parseLink(FULL), 1, 1);
  assert.equal(state, 'more-pages');
  assert.match(detail, /340 item\(s\)/);
});

test('next without last is its own state', () => {
  const header = '<https://api.github.com/repos/o/n/branches?page=2>; rel="next"';
  const [state, detail] = verdict(parseLink(header), 1, 1);
  assert.equal(state, 'more-pages-unsized');
  assert.match(detail, /rel="last"/);
});

test('page number is null when there is no page parameter', () => {
  assert.equal(pageNumber('https://api.github.com/repos/o/n/pulls?per_page=100'), null);
  assert.equal(pageNumber(null), null);
});

FAQ

How do I know the true number of items without reading every page?

Request the endpoint with per_page=1 and read the page number in the Link header's rel="last". At a page size of one, the last page number is the exact item count. It costs a single request, and it is the only cheap true count REST offers, since list endpoints carry no total_count field.

Why not just build the page URLs myself with ?page=2, ?page=3?

Because the format is GitHub's to change, and some endpoints have already moved to cursor-based paging where a page number means nothing. Following the URL in rel="next" is correct for both styles; constructing URLs is correct for one of them until it silently is not.

Is an empty array a safe signal to stop paginating?

It works, but it costs one wasted request every time and it is wrong on endpoints that can return an empty page in the middle of a result set. The documented termination condition is the absence of rel="next", which needs no extra call.

Can a script prove that my client is not following the Link header?

No, and no script can. GitHub keeps no record of how you parsed a response, so nothing in the API reports client behaviour. What a read-only script proves is that the endpoint has pages beyond the first and what the real total is; the comparison against your dashboard's number is the part a human does.

Does setting per_page=100 fix this?

No. It reduces the request count, which is worth doing, but a client that reads one page still reads one page. It will now confidently report 100 items instead of 30, which is a larger number and a more convincing one, so fix the loop first and the page size second.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.