Skip to content

Diagnostic GitHub API

the App's rate limit never grew with the installation

The App serves an organization with four hundred repositories, and it throttles like a personal access token. Somebody quotes the number they remember — Apps get more, Apps scale with the installation, we will be fine at this volume — and the plan was built on it. GET /rate_limit says 5000, the same as the laptop script it replaced.

Read-only token Python and Node.js Tests included
A white board with writing on it
Photo by sarah b on Unsplash
The short answer

An App installation's hourly ceiling is not a flat number. It starts at 5,000 and grows with the size of the installation: an installation covering more than twenty repositories earns extra requests per repository beyond that, and one covering more than twenty users earns extra per user, to a maximum of 12,500 an hour. Organizations on GitHub Enterprise Cloud get a flat 15,000 instead.

The growth is earned by the installation, not by the organization. An installation with repository_selection: "selected" pointed at nine repositories inside a four-hundred-repository org is a nine-repository installation as far as the ceiling is concerned, and it will sit at 5,000 forever. So the number to compare is not your usage against your limit; it is your limit against the limit an installation of this shape is entitled to. When those two agree, the ceiling is real and the answer is to make fewer requests. When they disagree, the installation is narrower than you think it is.

The problem in plain words

The capacity plan was written against a number nobody read from the API. Apps scale is true enough to repeat and vague enough to be useless, and it gets turned into a spreadsheet where the ceiling is 12,500, the poll is every five minutes, and the whole thing fits with room to spare. Nothing in the plan is checkable until the integration is live, and by then the number that matters has already been decided by a checkbox on an installation screen.

Then it throttles, and the investigation goes to the wrong place. Everybody looks at usage, because usage is what a rate-limit problem is usually about: the drain per minute, the process that woke up and burnt three thousand calls, the poll interval that is too aggressive. All of that analysis is competent and none of it explains anything, because the usage is exactly what the plan predicted. It is the denominator that is wrong.

The fix that gets tried first is caching or backing off, which helps and does not resolve the confusion, because the ceiling stays stubbornly at the number a single-user token gets. There is a persistent suspicion that the App is somehow not authenticating as an App at all — that the token is a user token, or the JWT flow is broken — and that suspicion sends a week into the auth code, which is fine. A 5,000 ceiling on an installation token is not a sign of broken authentication. It is a sign of a small installation.

Apps scale, so12,500written into theplanThrottles at5,000the ceiling nevermovedUsage isauditedand matches theplanAuth issuspecteda week in the JWTcodeCaching boltedonwhich onlypostpones itand the usage is audited again
Every number in the investigation is correct except the one nobody read. The usage matches the plan exactly, which is what makes it so hard to look at the other side of the fraction.

Why it happens

The ceiling is a function of installation size. The baseline for an organization installation is 5,000 requests an hour. Beyond twenty repositories, each additional repository in the installation adds to the ceiling; beyond twenty users in the account, each additional user does the same. The sum is capped at 12,500 an hour. This is why two Apps doing identical work against the same organization can have ceilings two and a half times apart: one is installed on everything and one on a shortlist.

A selected installation does not count the repositories it cannot see. repository_selection is either all or selected, and when it is selected the installation's size is the length of that selection. Adding repositories to the org does nothing. This is the whole mechanism, and it is invisible from inside the App, because the App's own view of the world is the selection.

Enterprise Cloud replaces the formula rather than extending it. An installation on an Enterprise Cloud organization gets 15,000 an hour flat. That is above the 12,500 cap, so it is not reachable by widening an ordinary installation, and seeing exactly 15,000 tells you which kind of account you are on without asking anybody.

This is not the same problem as running out. Draining an hourly window is a usage problem with an arithmetic answer, and it has its own note. This one is upstream of that: the window you are draining is the wrong size, and no amount of care with the drain will change it. The two look identical in production — 403s and an exhausted bucket — and they have completely different repairs.

Half the formula is not readable from here, so the answer is a floor. A read-only installation token can count the repositories it reaches. It usually cannot count the members of the organization, which requires org-level read. Since the user term only ever adds to the ceiling, an entitlement computed from repositories alone is a lower bound: if the measured limit is already below that bound, the finding stands regardless of the member count. The script says which half it could see rather than presenting a guess as a calculation.

The fix, as a flow

Two GETs, one of which is free, and then arithmetic that needs no network at all. The rate-limit endpoint reports the ceiling this credential was actually given; the installation endpoint reports how big the installation is, in one item rather than a page of them. Everything after that is a formula, which is why it can be tested properly. One half of the formula is not readable from here, so the entitlement comes out as a floor rather than an estimate, and the script says which half it could see.

Measured ceilingagainst installation sizeBelow entitlement, selectedthe selection is the capAt 5,000, small installreal ceiling, spend lessAt the 12,500 capnothing left to earnMatches its sizethe ceiling is honest
The middle two rows are both 5,000 and they are opposite findings. One is a selection to widen, the other is a ceiling to live within.

How to fix it

Read the ceiling instead of quoting it

GET /rate_limit with the installation token, and look at resources.core.limit — the limit, not the remaining. That endpoint does not count against the quota it reports, so this check is free and can run on every deploy. Write the number down; it is the only figure in the conversation that is not somebody's recollection.

Ask the installation how big it is

GET /installation/repositories?per_page=1 returns total_count and repository_selection without fetching a page of repositories. A selected installation with a small count and a 5,000 ceiling is the finding in one line. An all installation with a small count means the account really is small, and the ceiling is honest.

Compute what an installation of that shape earns

Baseline 5,000, plus the per-repository and per-user increments above twenty of each, capped at 12,500; or 15,000 flat on Enterprise Cloud. Compare against the measured limit. Equal means the ceiling is correct and the repair is on the usage side. Measured below entitled means something about the installation is narrower than the size you fed in.

Turn the ceiling into a repository budget before you widen anything

Divide the ceiling by the calls your loop makes per repository per hour. That gives the number of repositories the integration can actually service, which is the number worth arguing about in a planning meeting. Widening the installation raises both sides of that fraction — more repositories to service, and a higher ceiling — so it is not automatically a win, and the arithmetic says which way it falls.

Prefer spending fewer requests to earning more of them

Widening an installation to buy quota is a bad trade if the App does not need the reach: it is more access than the job requires, for at most two and a half times the requests. Conditional requests, a larger per_page, and one GraphQL query in place of a fan-out of REST calls all move the same needle without asking an owner for more of their organization.

How to check it worked

After the installation is widened to all repositories, or after the plan is rewritten against the real ceiling, the same free read confirms it.

GITHUB_INSTALLATION_TOKEN=$INSTALL_TOKEN python3 github_app_limit_ceiling.py --calls-per-repo 12
# core ceiling: 5000/hour, graphql 5000/hour
# installation: repository_selection=selected, 9 repository/repositories reachable
# narrow-installation: the ceiling is 5000/hour, and an installation covering
# 400 repositories would be entitled to at least 12500/hour
# budget: 5000/hour serves 416 repositories at 12 call(s) each

# after widening the installation to all repositories
# scaled: the ceiling is 12500/hour, which matches an installation this size

The full code

Two GETs, one of them free, and everything after them is arithmetic that can be tested without a network. The entitlement function is the centre of it: baseline, two increments that only start above twenty, a cap, and an Enterprise Cloud branch that replaces the sum rather than adding to it. It takes an unknown user count as zero on purpose, which makes its answer a floor rather than an estimate, and the verdict is careful never to claim a shortfall it computed from a number it could not read.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 101 GitHub API fixes, free and open source.
github_app_limit_ceiling.py
"""Say whether a GitHub App installation has the rate-limit ceiling it earns.

Read only. Two GETs: the rate-limit endpoint, which does not consume quota, and
a one-item page of the installation's repositories, which is the cheapest way to
learn how big the installation is. Nothing is minted, widened or changed.

An installation's hourly ceiling starts at 5,000 and grows with the number of
repositories and users the installation covers, to a maximum of 12,500; an
Enterprise Cloud organization gets a flat 15,000 instead. An installation
restricted to a handful of selected repositories never earns the growth, so a
large organization behind a narrow installation throttles at the same ceiling a
single user gets.

This is not the note about draining an hourly window. It is about the size of
the window before anything draws from it.

Environment:

    GITHUB_INSTALLATION_TOKEN   an installation access token
"""
import argparse
import json
import logging
import os
import sys

import requests

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

API = "https://api.github.com"
UA = "github-app-limit-ceiling/1.0"

# The documented shape of the ceiling. Kept as named constants because every one
# of them turns up in the output, and a reader comparing the script against the
# documentation should be able to find them in one place.
BASELINE = 5000
PER_UNIT = 50
SCALING_FLOOR = 20
FREE_CEILING = 12500
ENTERPRISE_CEILING = 15000
ANONYMOUS = 60


def entitled(repositories, users=None, enterprise=False):
    """The hourly ceiling an installation of this size earns. Pure.

    users is allowed to be None, because a read-only installation token usually
    cannot count the members of an organization. The user term only ever adds,
    so treating an unknown count as zero makes the result a lower bound rather
    than a guess: a measured limit below this number is a real shortfall
    whatever the true membership is.
    """
    if enterprise:
        return ENTERPRISE_CEILING
    try:
        repos = int(repositories or 0)
    except (TypeError, ValueError):
        repos = 0
    try:
        people = int(users or 0)
    except (TypeError, ValueError):
        people = 0
    extra = max(0, repos - SCALING_FLOOR) + max(0, people - SCALING_FLOOR)
    return min(FREE_CEILING, BASELINE + PER_UNIT * extra)


def is_lower_bound(users):
    """Whether the entitlement was computed without the user term. Pure."""
    return users is None


def classify_ceiling(limit):
    """Name the ceiling a credential was actually given. Pure.

    The names matter more than the numbers downstream, because the repair for a
    ceiling that sits at the floor and the repair for one already at the cap are
    opposite pieces of advice.
    """
    try:
        value = int(limit)
    except (TypeError, ValueError):
        return "unknown"
    if value == ANONYMOUS:
        return "unauthenticated"
    if value == ENTERPRISE_CEILING:
        return "enterprise"
    if value == FREE_CEILING:
        return "at-cap"
    if value == BASELINE:
        return "baseline"
    if BASELINE < value < FREE_CEILING:
        return "scaled"
    return "unknown"


def selection_of(view):
    """The repository_selection on an installation view, normalised. Pure."""
    if not isinstance(view, dict):
        return "unknown"
    raw = str(view.get("repository_selection") or "").strip().lower()
    return raw if raw in ("all", "selected") else "unknown"


def reachable(view):
    """How many repositories the installation covers, or None. Pure."""
    if not isinstance(view, dict):
        return None
    raw = view.get("total_count")
    if raw is None:
        return None
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def shortfall(limit, entitlement):
    """Requests an hour the installation is not getting, or 0. Pure."""
    try:
        have, earned = int(limit), int(entitlement)
    except (TypeError, ValueError):
        return 0
    return max(0, earned - have)


def sustainable_repos(limit, calls_per_repo):
    """How many repositories a loop of this cost fits under the ceiling. Pure."""
    try:
        ceiling, cost = int(limit), int(calls_per_repo)
    except (TypeError, ValueError):
        return None
    if cost <= 0:
        return None
    return ceiling // cost


def verdict(limit, selection, covered, account_repos=None, users=None,
            enterprise=False, installation_seen=True):
    """Turn the two reads into a finding. Pure.

    covered is the size of the installation. account_repos, where the caller
    could learn it, is the size of the account behind the installation: the
    difference between those two is what makes a narrow installation narrow.
    """
    klass = classify_ceiling(limit)
    if klass == "unauthenticated":
        return ("unauthenticated",
                "the ceiling is 60/hour, which is the anonymous ceiling. This "
                "credential is not reaching GitHub as an installation at all.")
    if not installation_seen:
        return ("not-an-installation",
                "the ceiling is %s/hour and the installation endpoint did not "
                "answer, so this is a user or Actions credential rather than an "
                "installation token. Installation scaling does not apply to it."
                % limit)
    if klass == "enterprise":
        return ("enterprise",
                "the ceiling is 15000/hour, the flat Enterprise Cloud ceiling. "
                "Widening the installation cannot raise it further.")
    earned = entitled(account_repos if account_repos is not None else covered,
                      users, enterprise)
    if klass == "at-cap":
        return ("at-cap",
                "the ceiling is 12500/hour, the maximum outside Enterprise "
                "Cloud. There is no more to earn: spend fewer requests.")
    gap = shortfall(limit, earned)
    if gap and selection == "selected":
        return ("narrow-installation",
                "the ceiling is %s/hour, and an installation covering %s "
                "repositories would be entitled to at least %d/hour. The "
                "selection is what is capping it, not the account."
                % (limit, account_repos if account_repos is not None else covered,
                   earned))
    if gap:
        return ("below-entitlement",
                "the ceiling is %s/hour against an entitlement of at least "
                "%d/hour for this size. The installation is narrower than the "
                "size used for the comparison." % (limit, earned))
    if klass == "baseline":
        return ("baseline",
                "the ceiling is 5000/hour and the installation covers %s "
                "repositories, which is too few to earn any scaling. This "
                "ceiling is real: the repair is on the usage side."
                % covered)
    return ("scaled",
            "the ceiling is %s/hour, which matches an installation this size."
            % limit)


def repair(state, covered=None, account_repos=None):
    """The sentence a reader has to act on. Pure."""
    if state == "narrow-installation":
        return ("widen the installation to all repositories if the App "
                "legitimately needs org-wide reach, which raises the ceiling as "
                "a side effect. If it does not, keep the narrow selection and "
                "cut request volume instead: conditional requests, a bigger "
                "per_page, one GraphQL query for a fan-out of REST calls.")
    if state in ("at-cap", "enterprise", "baseline"):
        return ("nothing on the installation. This ceiling is the one you get, "
                "so the only lever left is spending fewer requests per unit of "
                "work.")
    if state == "unauthenticated":
        return ("send the installation access token in the Authorization "
                "header. Nothing about scaling matters while the requests are "
                "arriving anonymously.")
    if state == "not-an-installation":
        return ("point the check at an installation access token. A user token "
                "gets a flat 5000 and never scales, so comparing it against an "
                "installation entitlement is meaningless.")
    if state == "below-entitlement":
        return ("check repository_selection and the account behind the "
                "installation before widening anything: the numbers disagree "
                "for a reason this script could not see.")
    return "nothing."


def get(session, path):
    """One GET. Returns (status, json-or-None)."""
    url = API + path if path.startswith("/") else path
    r = session.get(url, timeout=30)
    try:
        body = r.json()
    except ValueError:
        body = None
    return r.status_code, body


def core_limits(session):
    """The core and graphql ceilings from the free rate-limit endpoint."""
    status, body = get(session, "/rate_limit")
    if status != 200 or not isinstance(body, dict):
        log.error("GET /rate_limit returned %d", status)
        return None, None
    resources = body.get("resources") or {}
    core = (resources.get("core") or {}).get("limit")
    graphql = (resources.get("graphql") or {}).get("limit")
    return core, graphql


def installation_view(session):
    """total_count and repository_selection, without fetching repositories."""
    status, body = get(session, "/installation/repositories?per_page=1")
    if status != 200 or not isinstance(body, dict):
        return None
    return body


def account_size(session, org):
    """Repositories on the account behind the installation, where readable."""
    if not org:
        return None
    status, body = get(session, "/orgs/%s" % org)
    if status != 200 or not isinstance(body, dict):
        log.info("GET /orgs/%s returned %d; the account size is not readable "
                 "from here, so the comparison uses the installation size",
                 org, status)
        return None
    public = body.get("public_repos") or 0
    private = body.get("total_private_repos") or 0
    try:
        return int(public) + int(private)
    except (TypeError, ValueError):
        return None


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--org", default=os.environ.get("GITHUB_ORG"),
                    help="the account behind the installation, to size it")
    ap.add_argument("--users", type=int, default=None,
                    help="members of that account, if you know it; the API "
                         "rarely tells a read-only installation token")
    ap.add_argument("--calls-per-repo", type=int, default=10,
                    help="calls your loop makes per repository per hour")
    ap.add_argument("--enterprise", action="store_true",
                    help="the account is on GitHub Enterprise Cloud")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_INSTALLATION_TOKEN")
    if not token:
        log.error("set GITHUB_INSTALLATION_TOKEN to an installation access "
                  "token. A user token has a flat ceiling and nothing here "
                  "applies to it")
        return 2

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    })

    core, graphql = core_limits(session)
    if core is None:
        return 2
    log.info("core ceiling: %s/hour, graphql %s/hour", core, graphql)

    view = installation_view(session)
    covered = reachable(view)
    selection = selection_of(view)
    if view is None:
        log.info("the installation endpoint did not answer for this token")
    else:
        log.info("installation: repository_selection=%s, %s repository/"
                 "repositories reachable", selection, covered)

    org_repos = account_size(session, args.org) if selection == "selected" else None
    state, detail = verdict(core, selection, covered, org_repos, args.users,
                            args.enterprise, installation_seen=view is not None)
    log.info("%s: %s", state, detail)
    log.info("repair: %s", repair(state, covered, org_repos))

    fits = sustainable_repos(core, args.calls_per_repo)
    if fits is not None:
        log.info("budget: %s/hour serves %d repositories at %d call(s) each",
                 core, fits, args.calls_per_repo)

    print(json.dumps({
        "core_limit": core,
        "graphql_limit": graphql,
        "repository_selection": selection,
        "repositories_covered": covered,
        "account_repositories": org_repos,
        "entitlement_is_lower_bound": is_lower_bound(args.users),
        "entitled": entitled(org_repos if org_repos is not None else covered,
                             args.users, args.enterprise),
        "ceiling_class": classify_ceiling(core),
        "state": state,
        "detail": detail,
        "repositories_supported": fits,
    }, indent=2, default=str))
    return 1 if state in ("narrow-installation", "below-entitlement",
                          "unauthenticated") else 0


if __name__ == "__main__":
    sys.exit(main())
github-app-limit-ceiling.mjs
/**
 * Say whether a GitHub App installation has the rate-limit ceiling it earns.
 *
 * Read only. Two GETs: the rate-limit endpoint, which does not consume quota,
 * and a one-item page of the installation's repositories. Nothing is minted,
 * widened or changed.
 *
 * Environment:
 *   GITHUB_INSTALLATION_TOKEN  an installation access token
 *   GITHUB_ORG                 optional, the account behind the installation
 */
const API = 'https://api.github.com';
const UA = 'github-app-limit-ceiling/1.0';

/** The documented shape of the ceiling. */
export const BASELINE = 5000;
export const PER_UNIT = 50;
export const SCALING_FLOOR = 20;
export const FREE_CEILING = 12500;
export const ENTERPRISE_CEILING = 15000;
export const ANONYMOUS = 60;

/**
 * The hourly ceiling an installation of this size earns. Pure.
 * users may be null: the user term only adds, so an unknown count makes the
 * answer a lower bound rather than a guess.
 */
export function entitled(repositories, users = null, enterprise = false) {
  if (enterprise) return ENTERPRISE_CEILING;
  const repos = Number.isFinite(Number(repositories)) ? Number(repositories) : 0;
  const people = Number.isFinite(Number(users)) ? Number(users) : 0;
  const extra = Math.max(0, repos - SCALING_FLOOR) + Math.max(0, people - SCALING_FLOOR);
  return Math.min(FREE_CEILING, BASELINE + PER_UNIT * extra);
}

/** Whether the entitlement was computed without the user term. Pure. */
export function isLowerBound(users) {
  return users === null || users === undefined;
}

/** Name the ceiling a credential was actually given. Pure. */
export function classifyCeiling(limit) {
  const value = Number(limit);
  if (!Number.isFinite(value)) return 'unknown';
  if (value === ANONYMOUS) return 'unauthenticated';
  if (value === ENTERPRISE_CEILING) return 'enterprise';
  if (value === FREE_CEILING) return 'at-cap';
  if (value === BASELINE) return 'baseline';
  if (value > BASELINE && value < FREE_CEILING) return 'scaled';
  return 'unknown';
}

/** The repository_selection on an installation view, normalised. Pure. */
export function selectionOf(view) {
  if (!view || typeof view !== 'object') return 'unknown';
  const raw = String(view.repository_selection ?? '').trim().toLowerCase();
  return ['all', 'selected'].includes(raw) ? raw : 'unknown';
}

/** How many repositories the installation covers, or null. Pure. */
export function reachable(view) {
  if (!view || typeof view !== 'object') return null;
  const raw = view.total_count;
  if (raw === null || raw === undefined) return null;
  const n = Number(raw);
  return Number.isFinite(n) ? Math.trunc(n) : null;
}

/** Requests an hour the installation is not getting, or 0. Pure. */
export function shortfall(limit, entitlement) {
  const num = (v) => (v === null || v === undefined || v === '' ? NaN : Number(v));
  const have = num(limit);
  const earned = num(entitlement);
  if (!Number.isFinite(have) || !Number.isFinite(earned)) return 0;
  return Math.max(0, earned - have);
}

/** How many repositories a loop of this cost fits under the ceiling. Pure. */
export function sustainableRepos(limit, callsPerRepo) {
  const ceiling = Number(limit);
  const cost = Number(callsPerRepo);
  if (!Number.isFinite(ceiling) || !Number.isFinite(cost) || cost <= 0) return null;
  return Math.floor(ceiling / cost);
}

/** Turn the two reads into a finding. Pure. */
export function verdict(limit, selection, covered, accountRepos = null,
                        users = null, enterprise = false, installationSeen = true) {
  const klass = classifyCeiling(limit);
  if (klass === 'unauthenticated') {
    return ['unauthenticated',
      'the ceiling is 60/hour, which is the anonymous ceiling. This credential '
      + 'is not reaching GitHub as an installation at all.'];
  }
  if (!installationSeen) {
    return ['not-an-installation',
      `the ceiling is ${limit}/hour and the installation endpoint did not `
      + 'answer, so this is a user or Actions credential rather than an '
      + 'installation token. Installation scaling does not apply to it.'];
  }
  if (klass === 'enterprise') {
    return ['enterprise',
      'the ceiling is 15000/hour, the flat Enterprise Cloud ceiling. Widening '
      + 'the installation cannot raise it further.'];
  }
  const size = accountRepos === null || accountRepos === undefined ? covered : accountRepos;
  const earned = entitled(size, users, enterprise);
  if (klass === 'at-cap') {
    return ['at-cap',
      'the ceiling is 12500/hour, the maximum outside Enterprise Cloud. There '
      + 'is no more to earn: spend fewer requests.'];
  }
  const gap = shortfall(limit, earned);
  if (gap && selection === 'selected') {
    return ['narrow-installation',
      `the ceiling is ${limit}/hour, and an installation covering ${size} `
      + `repositories would be entitled to at least ${earned}/hour. The `
      + 'selection is what is capping it, not the account.'];
  }
  if (gap) {
    return ['below-entitlement',
      `the ceiling is ${limit}/hour against an entitlement of at least `
      + `${earned}/hour for this size. The installation is narrower than the `
      + 'size used for the comparison.'];
  }
  if (klass === 'baseline') {
    return ['baseline',
      `the ceiling is 5000/hour and the installation covers ${covered} `
      + 'repositories, which is too few to earn any scaling. This ceiling is '
      + 'real: the repair is on the usage side.'];
  }
  return ['scaled', `the ceiling is ${limit}/hour, which matches an installation this size.`];
}

/** The sentence a reader has to act on. Pure. */
export function repair(state) {
  if (state === 'narrow-installation') {
    return 'widen the installation to all repositories if the App legitimately '
      + 'needs org-wide reach, which raises the ceiling as a side effect. If it '
      + 'does not, keep the narrow selection and cut request volume instead: '
      + 'conditional requests, a bigger per_page, one GraphQL query for a '
      + 'fan-out of REST calls.';
  }
  if (['at-cap', 'enterprise', 'baseline'].includes(state)) {
    return 'nothing on the installation. This ceiling is the one you get, so '
      + 'the only lever left is spending fewer requests per unit of work.';
  }
  if (state === 'unauthenticated') {
    return 'send the installation access token in the Authorization header. '
      + 'Nothing about scaling matters while the requests are arriving anonymously.';
  }
  if (state === 'not-an-installation') {
    return 'point the check at an installation access token. A user token gets '
      + 'a flat 5000 and never scales, so comparing it against an installation '
      + 'entitlement is meaningless.';
  }
  if (state === 'below-entitlement') {
    return 'check repository_selection and the account behind the installation '
      + 'before widening anything: the numbers disagree for a reason this '
      + 'script could not see.';
  }
  return 'nothing.';
}

function headers(token) {
  return {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': UA,
  };
}

async function get(token, path) {
  const res = await fetch(API + path, { headers: headers(token) });
  let body = null;
  try { body = await res.json(); } catch { body = null; }
  return { status: res.status, body };
}

async function main() {
  const token = process.env.GITHUB_INSTALLATION_TOKEN;
  if (!token) {
    console.error('set GITHUB_INSTALLATION_TOKEN to an installation access token');
    process.exitCode = 2;
    return;
  }
  const org = process.env.GITHUB_ORG || null;
  const callsPerRepo = Number(process.env.GITHUB_CALLS_PER_REPO || 10);

  const rate = await get(token, '/rate_limit');
  if (rate.status !== 200 || !rate.body) {
    console.error(`GET /rate_limit returned ${rate.status}`);
    process.exitCode = 2;
    return;
  }
  const resources = rate.body.resources || {};
  const core = (resources.core || {}).limit;
  const graphql = (resources.graphql || {}).limit;
  console.log(`core ceiling: ${core}/hour, graphql ${graphql}/hour`);

  const inst = await get(token, '/installation/repositories?per_page=1');
  const view = inst.status === 200 ? inst.body : null;
  const covered = reachable(view);
  const selection = selectionOf(view);
  if (view) {
    console.log(`installation: repository_selection=${selection}, ${covered} reachable`);
  }

  let orgRepos = null;
  if (org && selection === 'selected') {
    const o = await get(token, `/orgs/${org}`);
    if (o.status === 200 && o.body) {
      orgRepos = Number(o.body.public_repos || 0) + Number(o.body.total_private_repos || 0);
    } else {
      console.log(`GET /orgs/${org} returned ${o.status}; using the installation size`);
    }
  }

  const [state, detail] = verdict(core, selection, covered, orgRepos, null,
    false, view !== null);
  console.log(`${state}: ${detail}`);
  console.log(`repair: ${repair(state)}`);
  const fits = sustainableRepos(core, callsPerRepo);
  if (fits !== null) {
    console.log(`budget: ${core}/hour serves ${fits} repositories at ${callsPerRepo} call(s) each`);
  }
  console.log(JSON.stringify({
    core_limit: core,
    graphql_limit: graphql,
    repository_selection: selection,
    repositories_covered: covered,
    account_repositories: orgRepos,
    entitlement_is_lower_bound: isLowerBound(null),
    state,
    repositories_supported: fits,
  }, null, 2));
  process.exitCode = ['narrow-installation', 'below-entitlement', 'unauthenticated']
    .includes(state) ? 1 : 0;
}

// Guarded so importing this file from the test runner does not start a run and
// fail the suite 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

The entitlement function gets the most attention, because every wrong answer downstream starts there: the increments must not begin below twenty of either kind, the two of them must add rather than compete, the cap must bind, and Enterprise Cloud must replace the sum instead of extending it. After that, the distinction the script exists to make — a 5,000 ceiling on a nine-repository installation is honest, and the same ceiling on a four-hundred-repository account is the finding — plus the refusal to treat an unknown user count as a reason to soften either answer.

test_github_app_limit_ceiling.py
from github_app_limit_ceiling import (
    classify_ceiling, entitled, is_lower_bound, reachable, repair,
    selection_of, shortfall, sustainable_repos, verdict,
)

WIDE = {"total_count": 400, "repository_selection": "all"}
NARROW = {"total_count": 9, "repository_selection": "selected"}


def test_nothing_scales_below_twenty_of_either_kind():
    assert entitled(0, 0) == 5000
    assert entitled(20, 20) == 5000
    assert entitled(19, 19) == 5000


def test_repositories_and_users_both_add():
    assert entitled(21, 0) == 5050
    assert entitled(0, 21) == 5050
    assert entitled(21, 21) == 5100


def test_the_cap_binds_outside_enterprise_cloud():
    assert entitled(1000, 1000) == 12500
    assert entitled(400, None) == 12500


def test_enterprise_replaces_the_sum_rather_than_extending_it():
    assert entitled(0, 0, enterprise=True) == 15000
    assert entitled(5000, 5000, enterprise=True) == 15000


def test_an_unknown_user_count_makes_the_answer_a_floor():
    assert entitled(30, None) == entitled(30, 0)
    assert entitled(30, 40) > entitled(30, None)
    assert is_lower_bound(None)
    assert not is_lower_bound(0)


def test_each_ceiling_has_a_name():
    assert classify_ceiling(60) == "unauthenticated"
    assert classify_ceiling(5000) == "baseline"
    assert classify_ceiling(7200) == "scaled"
    assert classify_ceiling(12500) == "at-cap"
    assert classify_ceiling(15000) == "enterprise"
    assert classify_ceiling(None) == "unknown"


def test_the_installation_view_is_read_defensively():
    assert selection_of(NARROW) == "selected"
    assert selection_of({"repository_selection": "ALL "}) == "all"
    assert selection_of({}) == "unknown"
    assert selection_of(None) == "unknown"
    assert reachable(WIDE) == 400
    assert reachable({"total_count": None}) is None
    assert reachable(None) is None


def test_a_small_installation_at_five_thousand_is_honest():
    state, detail = verdict(5000, "all", 9)
    assert state == "baseline"
    assert "repair is on the usage side" in detail


def test_a_narrow_installation_on_a_big_account_is_the_finding():
    state, detail = verdict(5000, "selected", 9, account_repos=400)
    assert state == "narrow-installation"
    assert "12500" in detail
    assert "selection is what is capping it" in detail


def test_a_scaled_ceiling_that_matches_its_size_is_not_a_finding():
    assert verdict(entitled(60, None), "all", 60)[0] == "scaled"


def test_the_cap_and_enterprise_are_never_reported_as_shortfalls():
    assert verdict(12500, "selected", 900, account_repos=4000)[0] == "at-cap"
    assert verdict(15000, "all", 4000)[0] == "enterprise"


def test_an_anonymous_ceiling_is_not_an_installation_problem():
    state, _ = verdict(60, "unknown", None, installation_seen=False)
    assert state == "unauthenticated"


def test_a_credential_with_no_installation_view_is_named_as_such():
    state, detail = verdict(5000, "unknown", None, installation_seen=False)
    assert state == "not-an-installation"
    assert "user or Actions credential" in detail


def test_the_shortfall_never_goes_negative():
    assert shortfall(12500, 5000) == 0
    assert shortfall(5000, 12500) == 7500
    assert shortfall(None, 12500) == 0


def test_the_budget_divides_the_ceiling_by_the_loop():
    assert sustainable_repos(12500, 10) == 1250
    assert sustainable_repos(5000, 12) == 416
    assert sustainable_repos(5000, 0) is None


def test_the_repair_for_a_real_ceiling_does_not_suggest_widening():
    assert "widen" not in repair("baseline")
    assert "widen the installation" in repair("narrow-installation")
github-app-limit-ceiling.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  classifyCeiling, entitled, isLowerBound, reachable, repair,
  selectionOf, shortfall, sustainableRepos, verdict,
} from './github-app-limit-ceiling.mjs';

const WIDE = { total_count: 400, repository_selection: 'all' };
const NARROW = { total_count: 9, repository_selection: 'selected' };

test('nothing scales below twenty of either kind', () => {
  assert.equal(entitled(0, 0), 5000);
  assert.equal(entitled(20, 20), 5000);
  assert.equal(entitled(19, 19), 5000);
});

test('repositories and users both add', () => {
  assert.equal(entitled(21, 0), 5050);
  assert.equal(entitled(0, 21), 5050);
  assert.equal(entitled(21, 21), 5100);
});

test('the cap binds outside Enterprise Cloud', () => {
  assert.equal(entitled(1000, 1000), 12500);
  assert.equal(entitled(400, null), 12500);
});

test('enterprise replaces the sum rather than extending it', () => {
  assert.equal(entitled(0, 0, true), 15000);
  assert.equal(entitled(5000, 5000, true), 15000);
});

test('an unknown user count makes the answer a floor', () => {
  assert.equal(entitled(30, null), entitled(30, 0));
  assert.ok(entitled(30, 40) > entitled(30, null));
  assert.ok(isLowerBound(null));
  assert.ok(!isLowerBound(0));
});

test('each ceiling has a name', () => {
  assert.equal(classifyCeiling(60), 'unauthenticated');
  assert.equal(classifyCeiling(5000), 'baseline');
  assert.equal(classifyCeiling(7200), 'scaled');
  assert.equal(classifyCeiling(12500), 'at-cap');
  assert.equal(classifyCeiling(15000), 'enterprise');
  assert.equal(classifyCeiling(null), 'unknown');
});

test('the installation view is read defensively', () => {
  assert.equal(selectionOf(NARROW), 'selected');
  assert.equal(selectionOf({ repository_selection: 'ALL ' }), 'all');
  assert.equal(selectionOf({}), 'unknown');
  assert.equal(selectionOf(null), 'unknown');
  assert.equal(reachable(WIDE), 400);
  assert.equal(reachable({ total_count: null }), null);
  assert.equal(reachable(null), null);
});

test('a small installation at five thousand is honest', () => {
  const [state, detail] = verdict(5000, 'all', 9);
  assert.equal(state, 'baseline');
  assert.match(detail, /repair is on the usage side/);
});

test('a narrow installation on a big account is the finding', () => {
  const [state, detail] = verdict(5000, 'selected', 9, 400);
  assert.equal(state, 'narrow-installation');
  assert.match(detail, /12500/);
  assert.match(detail, /selection is what is capping it/);
});

test('a scaled ceiling that matches its size is not a finding', () => {
  assert.equal(verdict(entitled(60, null), 'all', 60)[0], 'scaled');
});

test('the cap and enterprise are never reported as shortfalls', () => {
  assert.equal(verdict(12500, 'selected', 900, 4000)[0], 'at-cap');
  assert.equal(verdict(15000, 'all', 4000)[0], 'enterprise');
});

test('an anonymous ceiling is not an installation problem', () => {
  assert.equal(verdict(60, 'unknown', null, null, null, false, false)[0], 'unauthenticated');
});

test('a credential with no installation view is named as such', () => {
  const [state, detail] = verdict(5000, 'unknown', null, null, null, false, false);
  assert.equal(state, 'not-an-installation');
  assert.match(detail, /user or Actions credential/);
});

test('the shortfall never goes negative', () => {
  assert.equal(shortfall(12500, 5000), 0);
  assert.equal(shortfall(5000, 12500), 7500);
  assert.equal(shortfall(null, 12500), 0);
});

test('the budget divides the ceiling by the loop', () => {
  assert.equal(sustainableRepos(12500, 10), 1250);
  assert.equal(sustainableRepos(5000, 12), 416);
  assert.equal(sustainableRepos(5000, 0), null);
});

test('the repair for a real ceiling does not suggest widening', () => {
  assert.ok(!repair('baseline').includes('widen'));
  assert.match(repair('narrow-installation'), /widen the installation/);
});

FAQ

How is this different from the note about running out of quota?

That note is about the drain: how fast you are spending the hour you have, and when the bucket empties. This one is about the size of the bucket. They produce the same production symptom, a wall of 403s with an exhausted core resource, and the repairs have nothing in common. If your ceiling is the one an installation of your size earns, the drain note is the one you want. If your ceiling is 5,000 while the installation covers a large organization, spending less will only postpone the problem you actually have.

Does widening the installation always help?

No, and the arithmetic is worth doing before anybody asks an owner for more access. Widening raises the ceiling and it also raises the amount of work, because there are now more repositories to poll, watch and reconcile. Divide the ceiling by the calls your loop makes per repository per hour, before and after. If the ratio gets worse, widening bought you a bigger number and a smaller margin, and you have also taken more access than the App needs, which is its own cost.

Why can the script not read the number of users?

Because counting the members of an organization needs org-level read, and a read-only installation token scoped to repositories does not have it. The script computes the entitlement from the repository term alone and labels the result a lower bound. That is enough to be useful: the user term only adds, so a measured ceiling below the repository-only entitlement is a genuine shortfall no membership figure can explain away. It is not enough to predict the exact ceiling you would get after widening, and the script does not pretend otherwise.

We see exactly 15,000. Is that a scaled installation?

No, it is the flat Enterprise Cloud ceiling, and it is above the 12,500 cap that ordinary scaling stops at. Seeing it is a useful fact in itself: it tells you which kind of account the installation sits on without asking anybody, and it tells you that widening cannot buy any more requests. The only lever left at that point is the number of requests per unit of work.

Our GitHub Actions job gets 1,000 an hour. Does any of this apply?

None of it. The token Actions injects is a different class of credential with its own repository-scoped pool, and it does not scale with anything. Comparing it against an installation entitlement produces a shortfall that is not real. The script notices when the installation endpoint does not answer for the credential it was given and says so rather than running the comparison anyway.

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.