Skip to content

Diagnostic GitHub API

GITHUB_TOKEN gets 1,000 an hour, shared across the repo

The script works on your laptop. It makes about 1,400 calls, finishes in two minutes, and nobody thinks about it again until it is moved into a workflow. There it gets to roughly call 1,000 and starts collecting 403. Same code, same repository, same endpoints. The only thing that changed is which credential is in the environment, and that credential was handed a ceiling a fifth the size of the one you tested against.

Read-only token Python and Node.js Tests included
Two people talking at a table inside a cafe
Photo by Thomas Leblanc on Unsplash
The short answer

The GITHUB_TOKEN that Actions injects is not a small personal access token. It is a different class of credential with a 1,000 request an hour core ceiling, 15,000 on Enterprise Cloud, and GraphQL gets 1,000 points an hour on the same credential. A user PAT gets 5,000.

The part that turns a budget into an outage is that the pool is per repository, not per job or per run. Every concurrent job in the run, every matrix leg, and every other workflow that fired in the same hour draw from the same 1,000 and reset on the same clock. The script below reads GET /rate_limit, names the credential class from the ceilings it was given, and then costs your workflow against the pool it will actually share, so the answer is a job number rather than a shrug.

The problem in plain words

The symptom is a workflow that fails at a different point every time. Job three is fine on Tuesday and job three is the one that dies on Wednesday, because the deciding factor is not job three: it is how much of the shared 1,000 the other jobs happened to spend first. A failure that moves is the hardest kind to attribute, and it is the natural consequence of a pool with several concurrent claimants.

It also fails the obvious test. Run the same script locally with your own token and it passes, because your own token has five times the allowance. Run it in a workflow with ACTIONS_STEP_DEBUG on and it still fails, because verbosity does not change arithmetic. The reproduction that works is the one nobody thinks to do: read the ceiling from inside the job.

And a matrix multiplies it invisibly. Four operating systems by three versions is twelve jobs, and if each one makes 120 calls that is 1,440 requests against a pool of 1,000 without a single line of the script changing. The matrix was added for coverage; it silently became a concurrency setting for your API budget.

Works on alaptop5000 an hourMoved intoActions1000 an hourMatrix expandsto 12120 calls eachPool empties at1000one repository,one clock403 in a movingjobreads as flaky
The failing job moves between reruns, because the deciding factor is not that job. It is how much of the shared pool the others spent first.

Why it happens

The ceiling is a property of the credential class. GET /rate_limit returns resources.core.limit, and the number is a fingerprint: 60 is anonymous, 1,000 is the Actions token, 5,000 is an authenticated user or a GitHub App installation at the floor, 15,000 is Enterprise Cloud, and anything above 5,000 that is not 15,000 is an App installation that has scaled with the repositories and users it covers.

1,000 has a second witness. The graphql row is 1,000 points an hour on the same credential, and GET /user answers 403 rather than 200, because the Actions token is not a user. Two independent signals agreeing is what turns "probably an Actions token" into a finding, and the script reports which of them it got.

The pool belongs to the repository. This is the sentence that explains the moving failure. Concurrency does not buy you more allowance; it spends the same allowance faster. Twelve jobs at 120 calls is one budget of 1,440, not twelve budgets of 120.

Reading remaining from inside the job is the only honest headroom. The limit is what you would have had at the top of the hour. What you actually have is what the rest of the repository has left you, and only a call made in the job can see it.

Raising the ceiling is the last move, not the first. A GitHub App installation lifts the floor to 5,000 and scales beyond it, which is the documented answer for API-heavy automation. But a job that needs 1,400 REST calls to build one summary usually needs four GraphQL queries instead, and conditional requests make the repeats free. Spend less before you buy more.

The fix, as a flow

Nothing in this check judges whether the credential is any good. It reads the ceilings the credential was handed, names the class from the shape of them, and then does arithmetic against a pool that belongs to the repository rather than to the job. The output is a job number, which is the only part anyone acts on.

GET /rate_limitcore and graphql rowsCore 1000, user 403Actions token, repo scopedRun costs more than thatjob N of M starves firstCore 5000user or App at the floor, ambiguousRun fits the poolnothing to reclaim
The core number is a fingerprint, and the graphql row and a 403 from GET /user corroborate it from two other directions.

How to fix it

Read the ceiling from inside the job, not from your laptop

Add a step that calls GET /rate_limit with the same credential the job uses and prints resources.core.limit and resources.core.remaining. The call is free — /rate_limit does not consume quota — and it is the only place the real number lives. A 1,000 here and a 5,000 on your machine is the entire explanation for "it works locally".

Corroborate the class with the graphql row and GET /user

The graphql row reading 1,000 points and GET /user answering 403 both point at the Actions token independently of the core number. If all three agree you are done identifying and can move on to counting. If they disagree, something is injecting a different credential than you think.

Count the calls the whole run makes, not the calls one job makes

Multiply jobs by matrix legs by calls per job. Include the jobs you do not think of as API clients: a step that posts nothing but reads a file list is still spending core requests, and an action you pulled from the marketplace spends them on your budget rather than on its own.

Cost that number against remaining, not against the limit

If two workflows fire on the same push, the second one starts with whatever the first left. The script accepts a remaining value read live so the report reflects the pool as it is rather than as it was at the top of the hour, and it names the first job index that runs dry.

Spend less before raising the ceiling

Collapse REST loops into GraphQL where you are fetching related objects, and send If-None-Match where you are re-fetching the same object: a 304 does not count against the primary limit. When the volume is genuinely irreducible, authenticate as a GitHub App installation instead of using the built-in token. That is a change of credential, not a change of code, and it is the documented route to a higher ceiling.

How to check it worked

Run the check as a step in the workflow and read the class and the costing together. The line you want is the one that names a job index, because that is the number a rerun will confirm.

python3 github_actions_token_budget.py --jobs 12 --calls 120
# actions-token (high): a core ceiling of 1000 an hour is the built-in Actions
#   token, and it belongs to the repository rather than to this job
# pool-overrun: 12 job(s) at 120 call(s) each is 1440 request(s) against a
#   pool of 1000 that the whole repository shares. Job 9 of 12 is the first
#   to start seeing 403.

The full code

One network call produces the finding and it is GET /rate_limit, which spends nothing. The optional GET /user is used for its status code alone; the body is never read, because the interesting thing about that request is that an Actions token is refused by it. Everything after that is arithmetic on plain numbers: a classifier over the ceilings, a costing over jobs and matrix legs, and a verdict. All three are pure, so the tests can hand them a 1,000 ceiling and an already-drained pool without needing either.

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_actions_token_budget.py
"""Cost a workflow against the request pool it shares with its own repository.

Read only. Every request is a GET. GET /rate_limit consumes no quota from any
bucket, and the optional identity probe is a single GET /user used for its
status code rather than its body.

The built-in Actions credential is not a small personal access token. It is a
different class with a 1,000 an hour core ceiling, and that ceiling belongs to
the repository rather than to the job, so every concurrent job and every matrix
leg in the run draws from the same pool on the same clock.
"""
import argparse
import json
import logging
import os
import sys
import time

import requests

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

API = "https://api.github.com"
UA = "github-actions-token-budget/1.0"

# Ceilings that identify a credential class outright. 5000 is deliberately not
# in here: it is both the authenticated-user allowance and the floor for a
# GitHub App installation, so on its own it names two things and settles none.
ACTIONS_CEILING = 1000
ANON_CEILING = 60
ENTERPRISE_CEILING = 15000
USER_CEILING = 5000


def classify(core_limit, graphql_limit=None, user_status=None):
    """Name the credential class from the ceilings it was handed. Pure.

    Returns (klass, confidence, note). Confidence matters here because one of
    the numbers is genuinely ambiguous and pretending otherwise sends people
    looking for an App installation they do not have.
    """
    try:
        core = int(core_limit)
    except (TypeError, ValueError):
        return ("unknown", "none",
                "GET /rate_limit reported no core limit, so there is no ceiling "
                "to cost anything against")

    if core <= 0:
        return ("unknown", "none", "a core limit of %d is not a ceiling" % core)
    if core <= ANON_CEILING:
        return ("anonymous", "high",
                "a core ceiling of %d is the anonymous tier, counted per "
                "originating IP address. No credential is reaching GitHub" % core)
    if core == ACTIONS_CEILING:
        seconds = []
        if user_status == 403:
            seconds.append("GET /user answered 403, which a user token never does")
        try:
            if int(graphql_limit) == ACTIONS_CEILING:
                seconds.append("the graphql row is 1000 points as well")
        except (TypeError, ValueError):
            pass
        note = ("a core ceiling of 1000 an hour is the built-in Actions token, "
                "and it belongs to the repository rather than to this job")
        if seconds:
            note += ". " + "; ".join(seconds)
        return ("actions-token", "high" if seconds else "likely", note)
    if core == ENTERPRISE_CEILING:
        return ("enterprise-user", "likely",
                "15000 an hour is a user on GitHub Enterprise Cloud")
    if core == USER_CEILING:
        return ("user-or-app", "ambiguous",
                "5000 an hour is an authenticated user token or a GitHub App "
                "installation still at the floor; the number names two things "
                "and settles neither")
    if core > USER_CEILING:
        return ("app-installation", "likely",
                "%d an hour is above the 5000 floor, which only a GitHub App "
                "installation scaled by repositories and users reaches" % core)
    return ("unknown", "none",
            "a core ceiling of %d does not match a documented class" % core)


def plan(jobs, calls_per_job, matrix_legs=1, ceiling=ACTIONS_CEILING, remaining=None):
    """What one workflow run costs against a pool the repository shares. Pure.

    `remaining` is the honest input and `ceiling` is the optimistic one: the
    limit is what you would have had at the top of the hour, and remaining is
    what the rest of the repository left you. The source is reported so a reader
    can tell which number the verdict was built on.
    """
    def whole(value, floor=0):
        try:
            return max(floor, int(value))
        except (TypeError, ValueError):
            return floor

    legs = max(1, whole(matrix_legs, 1))
    count = whole(jobs)
    calls = whole(calls_per_job)
    ceiling = max(1, whole(ceiling, 1))

    effective = count * legs
    total = effective * calls
    if remaining is None:
        headroom, source = ceiling, "limit"
    else:
        headroom, source = whole(remaining), "remaining"

    served = effective if not calls else min(effective, headroom // calls)
    return {"legs": legs, "jobs": effective, "calls_per_job": calls,
            "total": total, "headroom": headroom, "source": source,
            "fits": total <= headroom, "jobs_served": served,
            "first_starved_job": None if total <= headroom else served + 1,
            "shortfall": max(0, total - headroom)}


def pool_reset_in(reset, now):
    """Seconds until the shared pool refills, floored at zero. Pure.

    None rather than 0 when the value is unreadable, because "refills now" and
    "I could not read the reset" must not print the same.
    """
    try:
        return max(0, int(reset) - int(now))
    except (TypeError, ValueError):
        return None


def verdict(klass, costing):
    """Turn the class and the costing into a finding. Pure."""
    if klass == "anonymous":
        return ("unauthenticated",
                "the ceiling being costed is the anonymous 60 an hour, so this "
                "is not a workflow budget problem yet: no credential is "
                "arriving at GitHub.")
    if costing["total"] == 0:
        return ("no-workflow",
                "no workflow was described, so there is nothing to cost against "
                "the %d request pool." % costing["headroom"])
    if klass != "actions-token":
        return ("different-ceiling",
                "the credential in this environment reads as %s with a ceiling "
                "of %d, not the 1000 the Actions token gets. The %d request(s) "
                "this run makes fit here and will not fit there. Run the check "
                "from inside the job." % (klass, costing["headroom"], costing["total"]))
    if not costing["fits"]:
        return ("pool-overrun",
                "%d job(s) at %d call(s) each is %d request(s) against a pool "
                "of %d that the whole repository shares. Job %d of %d is the "
                "first to start seeing 403, and any other run in the same hour "
                "moves that number down."
                % (costing["jobs"], costing["calls_per_job"], costing["total"],
                   costing["headroom"], costing["first_starved_job"], costing["jobs"]))
    if costing["total"] * 5 >= costing["headroom"] * 4:
        return ("pool-tight",
                "%d request(s) against %d is over four fifths of a pool shared "
                "with every other job and every other run in this repository "
                "within the same hour."
                % (costing["total"], costing["headroom"]))
    return ("fits",
            "%d request(s) against a shared pool of %d."
            % (costing["total"], costing["headroom"]))


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


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--jobs", type=int, default=0,
                        help="jobs in the workflow run")
    parser.add_argument("--calls", type=int, default=0,
                        help="core API requests one job makes")
    parser.add_argument("--matrix", type=int, default=1,
                        help="matrix legs each job expands into")
    parser.add_argument("--env", default="GITHUB_TOKEN",
                        help="environment variable holding the credential")
    parser.add_argument("--use-limit", action="store_true",
                        help="cost against the hourly limit rather than what "
                             "the repository has left right now")
    args = parser.parse_args()

    token = os.environ.get(args.env)
    if not token:
        log.error("set %s. Inside a workflow this is the credential Actions "
                  "injects; on a laptop it is your own, and the whole point of "
                  "this check is that the two have different ceilings", args.env)
        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,
    })

    status, payload = get(session, "/rate_limit")
    if status != 200:
        log.error("GET /rate_limit returned %d; without it there is no ceiling "
                  "to reason about", status)
        return 2

    resources = ((payload or {}).get("resources") or {})
    core = resources.get("core") or {}
    graphql = resources.get("graphql") or {}

    user_status, _ = get(session, "/user")
    log.info("GET /user answered %d (used as a fingerprint, not for its body)",
             user_status)

    klass, confidence, note = classify(core.get("limit"), graphql.get("limit"),
                                       user_status)
    log.info("%s (%s): %s", klass, confidence, note)
    log.info("core limit %s remaining %s, graphql limit %s remaining %s",
             core.get("limit"), core.get("remaining"),
             graphql.get("limit"), graphql.get("remaining"))

    wait = pool_reset_in(core.get("reset"), time.time())
    if wait is not None:
        log.info("the shared pool refills in %ds", wait)

    if os.environ.get("GITHUB_ACTIONS") != "true":
        log.warning("GITHUB_ACTIONS is not set to true, so this is not running "
                    "inside a workflow and the ceiling below is your laptop's, "
                    "not the one the job will get")

    ceiling = core.get("limit") or ACTIONS_CEILING
    remaining = None if args.use_limit else core.get("remaining")
    costing = plan(args.jobs, args.calls, args.matrix, ceiling, remaining)
    log.info("costed against the %s: %d request(s) against %d",
             costing["source"], costing["total"], costing["headroom"])

    state, detail = verdict(klass, costing)
    log.info("%s: %s", state, detail)

    if state in ("pool-overrun", "pool-tight"):
        log.info("repair: collapse related REST reads into one GraphQL query, "
                 "and send If-None-Match on repeats. A 304 does not count "
                 "against the primary limit.")
        log.info("repair: for volume that cannot be reduced, authenticate as a "
                 "GitHub App installation instead of the built-in token. That "
                 "lifts the floor to 5000 and scales past it.")
        log.info("repair: reduce concurrency. The pool is per repository, so "
                 "matrix legs do not each get their own budget.")

    print(json.dumps({"class": klass, "confidence": confidence,
                      "plan": costing, "state": state}, indent=2))
    return 1 if state in ("pool-overrun", "pool-tight", "unauthenticated") else 0


if __name__ == "__main__":
    sys.exit(main())
github-actions-token-budget.mjs
/**
 * Cost a workflow against the request pool it shares with its own repository.
 *
 * Read only. Every request is a GET. GET /rate_limit consumes no quota, and the
 * identity probe is a single GET /user read for its status code alone.
 *
 * The built-in Actions credential has a 1,000 an hour core ceiling and that
 * ceiling belongs to the repository, so every concurrent job and every matrix
 * leg draws from the same pool on the same clock.
 */
const API = 'https://api.github.com';
const UA = 'github-actions-token-budget/1.0';

export const ACTIONS_CEILING = 1000;
export const ANON_CEILING = 60;
export const ENTERPRISE_CEILING = 15000;
export const USER_CEILING = 5000;

/**
 * Name the credential class from the ceilings it was handed. Pure.
 * Returns [klass, confidence, note]; 5000 is reported as ambiguous because it
 * names both a user token and an App installation at the floor.
 */
export function classify(coreLimit, graphqlLimit = null, userStatus = null) {
  const core = Number.parseInt(coreLimit, 10);
  if (!Number.isFinite(core)) {
    return ['unknown', 'none',
      'GET /rate_limit reported no core limit, so there is no ceiling to cost anything against'];
  }
  if (core <= 0) return ['unknown', 'none', `a core limit of ${core} is not a ceiling`];
  if (core <= ANON_CEILING) {
    return ['anonymous', 'high',
      `a core ceiling of ${core} is the anonymous tier, counted per originating ` +
      'IP address. No credential is reaching GitHub'];
  }
  if (core === ACTIONS_CEILING) {
    const seconds = [];
    if (userStatus === 403) seconds.push('GET /user answered 403, which a user token never does');
    if (Number.parseInt(graphqlLimit, 10) === ACTIONS_CEILING) {
      seconds.push('the graphql row is 1000 points as well');
    }
    let note = 'a core ceiling of 1000 an hour is the built-in Actions token, ' +
      'and it belongs to the repository rather than to this job';
    if (seconds.length) note += `. ${seconds.join('; ')}`;
    return ['actions-token', seconds.length ? 'high' : 'likely', note];
  }
  if (core === ENTERPRISE_CEILING) {
    return ['enterprise-user', 'likely', '15000 an hour is a user on GitHub Enterprise Cloud'];
  }
  if (core === USER_CEILING) {
    return ['user-or-app', 'ambiguous',
      '5000 an hour is an authenticated user token or a GitHub App installation ' +
      'still at the floor; the number names two things and settles neither'];
  }
  if (core > USER_CEILING) {
    return ['app-installation', 'likely',
      `${core} an hour is above the 5000 floor, which only a GitHub App ` +
      'installation scaled by repositories and users reaches'];
  }
  return ['unknown', 'none', `a core ceiling of ${core} does not match a documented class`];
}

/** What one workflow run costs against a pool the repository shares. Pure. */
export function plan(jobs, callsPerJob, matrixLegs = 1,
                     ceiling = ACTIONS_CEILING, remaining = null) {
  const whole = (value, floor = 0) => {
    const n = Number.parseInt(value, 10);
    return Number.isFinite(n) ? Math.max(floor, n) : floor;
  };
  const legs = Math.max(1, whole(matrixLegs, 1));
  const count = whole(jobs);
  const calls = whole(callsPerJob);
  const cap = Math.max(1, whole(ceiling, 1));

  const effective = count * legs;
  const total = effective * calls;
  const headroom = remaining === null || remaining === undefined ? cap : whole(remaining);
  const source = remaining === null || remaining === undefined ? 'limit' : 'remaining';
  const served = calls ? Math.min(effective, Math.floor(headroom / calls)) : effective;

  return {
    legs, jobs: effective, calls_per_job: calls, total, headroom, source,
    fits: total <= headroom, jobs_served: served,
    first_starved_job: total <= headroom ? null : served + 1,
    shortfall: Math.max(0, total - headroom),
  };
}

/** Seconds until the shared pool refills; null when unreadable. Pure. */
export function poolResetIn(reset, now) {
  const r = Number.parseInt(reset, 10);
  const n = Number.parseInt(now, 10);
  if (!Number.isFinite(r) || !Number.isFinite(n)) return null;
  return Math.max(0, r - n);
}

/** Turn the class and the costing into a finding. Pure. */
export function verdict(klass, costing) {
  if (klass === 'anonymous') {
    return ['unauthenticated',
      'the ceiling being costed is the anonymous 60 an hour, so this is not a ' +
      'workflow budget problem yet: no credential is arriving at GitHub.'];
  }
  if (costing.total === 0) {
    return ['no-workflow',
      `no workflow was described, so there is nothing to cost against the ${costing.headroom} request pool.`];
  }
  if (klass !== 'actions-token') {
    return ['different-ceiling',
      `the credential in this environment reads as ${klass} with a ceiling of ` +
      `${costing.headroom}, not the 1000 the Actions token gets. The ${costing.total} ` +
      'request(s) this run makes fit here and will not fit there. Run the check ' +
      'from inside the job.'];
  }
  if (!costing.fits) {
    return ['pool-overrun',
      `${costing.jobs} job(s) at ${costing.calls_per_job} call(s) each is ` +
      `${costing.total} request(s) against a pool of ${costing.headroom} that the ` +
      `whole repository shares. Job ${costing.first_starved_job} of ${costing.jobs} ` +
      'is the first to start seeing 403, and any other run in the same hour moves ' +
      'that number down.'];
  }
  if (costing.total * 5 >= costing.headroom * 4) {
    return ['pool-tight',
      `${costing.total} request(s) against ${costing.headroom} is over four fifths ` +
      'of a pool shared with every other job and every other run in this ' +
      'repository within the same hour.'];
  }
  return ['fits', `${costing.total} request(s) against a shared pool of ${costing.headroom}.`];
}

async function get(token, path) {
  const res = await fetch(API + path, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': UA,
    },
  });
  let body = null;
  try { body = await res.json(); } catch { body = null; }
  return { status: res.status, body };
}

async function main() {
  const envName = process.env.GITHUB_TOKEN_ENV || 'GITHUB_TOKEN';
  const token = process.env[envName];
  if (!token) {
    console.error(`set ${envName}. Inside a workflow this is the credential ` +
      'Actions injects; on a laptop it is your own, and the whole point of this ' +
      'check is that the two have different ceilings');
    process.exitCode = 2;
    return;
  }
  const jobs = Number.parseInt(process.argv[2] ?? '0', 10) || 0;
  const calls = Number.parseInt(process.argv[3] ?? '0', 10) || 0;
  const matrix = Number.parseInt(process.argv[4] ?? '1', 10) || 1;

  const rate = await get(token, '/rate_limit');
  if (rate.status !== 200) {
    console.error(`GET /rate_limit returned ${rate.status}; without it there is ` +
      'no ceiling to reason about');
    process.exitCode = 2;
    return;
  }

  const resources = (rate.body ?? {}).resources ?? {};
  const core = resources.core ?? {};
  const graphql = resources.graphql ?? {};

  const user = await get(token, '/user');
  console.log(`GET /user answered ${user.status} (a fingerprint, not a body read)`);

  const [klass, confidence, note] = classify(core.limit, graphql.limit, user.status);
  console.log(`${klass} (${confidence}): ${note}`);
  console.log(`core limit ${core.limit} remaining ${core.remaining}, ` +
    `graphql limit ${graphql.limit} remaining ${graphql.remaining}`);

  const wait = poolResetIn(core.reset, Math.floor(Date.now() / 1000));
  if (wait !== null) console.log(`the shared pool refills in ${wait}s`);

  if (process.env.GITHUB_ACTIONS !== 'true') {
    console.warn('GITHUB_ACTIONS is not set to true, so this is not running ' +
      'inside a workflow and the ceiling above is your laptop\'s');
  }

  const costing = plan(jobs, calls, matrix, core.limit || ACTIONS_CEILING, core.remaining);
  const [state, detail] = verdict(klass, costing);
  console.log(`${state}: ${detail}`);

  if (state === 'pool-overrun' || state === 'pool-tight') {
    console.log('repair: collapse related REST reads into one GraphQL query and ' +
      'send If-None-Match on repeats; a 304 does not count against the primary limit.');
    console.log('repair: for irreducible volume, authenticate as a GitHub App ' +
      'installation instead of the built-in token.');
    console.log('repair: reduce concurrency. The pool is per repository, so matrix ' +
      'legs do not each get their own budget.');
  }

  console.log(JSON.stringify({ class: klass, confidence, plan: costing, state }, null, 2));
  process.exitCode = ['pool-overrun', 'pool-tight', 'unauthenticated'].includes(state) ? 1 : 0;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err); process.exitCode = 2; });
}

Add a test

The cases worth pinning are the ones a live run will not produce on demand. A 5,000 ceiling has to come back as ambiguous rather than as a confident user token, because it is also an App installation at the floor. A 1,000 ceiling with two corroborating signals has to read differently from a 1,000 ceiling on its own. And the costing has to name a job index rather than a percentage, because the index is the only output anyone acts on. Every function takes plain numbers, so a drained pool is a literal rather than a fixture.

test_github_actions_token_budget.py
from github_actions_token_budget import classify, plan, pool_reset_in, verdict


def test_a_thousand_is_the_actions_token():
    klass, confidence, note = classify(1000)
    assert klass == "actions-token"
    assert confidence == "likely"
    assert "belongs to the repository" in note


def test_two_corroborating_signals_raise_the_confidence():
    klass, confidence, note = classify(1000, graphql_limit=1000, user_status=403)
    assert klass == "actions-token"
    assert confidence == "high"
    assert "403" in note and "1000 points" in note


def test_five_thousand_is_reported_as_ambiguous_rather_than_as_a_user():
    klass, confidence, _ = classify(5000)
    assert klass == "user-or-app"
    assert confidence == "ambiguous"


def test_the_scaled_and_enterprise_ceilings_are_separated():
    assert classify(15000)[0] == "enterprise-user"
    assert classify(12500)[0] == "app-installation"


def test_sixty_is_the_anonymous_tier_and_not_a_budget_problem():
    assert classify(60)[0] == "anonymous"
    assert verdict("anonymous", plan(4, 100))[0] == "unauthenticated"


def test_an_unreadable_ceiling_does_not_become_a_number():
    assert classify(None)[0] == "unknown"
    assert classify("plenty")[1] == "none"


def test_the_matrix_multiplies_the_job_count():
    costing = plan(jobs=4, calls_per_job=120, matrix_legs=3)
    assert costing["legs"] == 3
    assert costing["jobs"] == 12
    assert costing["total"] == 1440


def test_an_overrun_names_the_first_job_that_starves():
    costing = plan(jobs=12, calls_per_job=120, ceiling=1000)
    assert costing["fits"] is False
    assert costing["jobs_served"] == 8
    assert costing["first_starved_job"] == 9
    assert costing["shortfall"] == 440


def test_remaining_is_used_when_it_is_supplied_and_is_labelled():
    costing = plan(jobs=5, calls_per_job=100, remaining=240)
    assert costing["source"] == "remaining"
    assert costing["headroom"] == 240
    assert costing["first_starved_job"] == 3


def test_no_calls_is_not_a_division_by_zero():
    costing = plan(jobs=6, calls_per_job=0)
    assert costing["total"] == 0
    assert costing["fits"] is True
    assert costing["first_starved_job"] is None


def test_a_described_overrun_reads_as_a_job_number():
    state, detail = verdict("actions-token", plan(12, 120, ceiling=1000))
    assert state == "pool-overrun"
    assert "Job 9 of 12" in detail
    assert "whole repository shares" in detail


def test_four_fifths_of_the_pool_is_already_a_finding():
    state, detail = verdict("actions-token", plan(8, 100, ceiling=1000))
    assert state == "pool-tight"
    assert "four fifths" in detail


def test_a_run_well_inside_the_pool_is_reported_as_fitting():
    assert verdict("actions-token", plan(2, 50, ceiling=1000))[0] == "fits"


def test_costing_a_laptop_credential_says_so_rather_than_passing():
    state, detail = verdict("user-or-app", plan(12, 120, ceiling=5000))
    assert state == "different-ceiling"
    assert "from inside the job" in detail


def test_nothing_described_is_not_a_pass_either():
    assert verdict("actions-token", plan(0, 0))[0] == "no-workflow"


def test_the_reset_is_seconds_or_nothing():
    assert pool_reset_in(1000, 940) == 60
    assert pool_reset_in(900, 940) == 0
    assert pool_reset_in(None, 940) is None
github-actions-token-budget.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  classify, plan, poolResetIn, verdict,
} from './github-actions-token-budget.mjs';

test('a thousand is the actions token', () => {
  const [klass, confidence, note] = classify(1000);
  assert.equal(klass, 'actions-token');
  assert.equal(confidence, 'likely');
  assert.match(note, /belongs to the repository/);
});

test('two corroborating signals raise the confidence', () => {
  const [klass, confidence, note] = classify(1000, 1000, 403);
  assert.equal(klass, 'actions-token');
  assert.equal(confidence, 'high');
  assert.match(note, /403/);
  assert.match(note, /1000 points/);
});

test('five thousand is reported as ambiguous rather than as a user', () => {
  const [klass, confidence] = classify(5000);
  assert.equal(klass, 'user-or-app');
  assert.equal(confidence, 'ambiguous');
});

test('the scaled and enterprise ceilings are separated', () => {
  assert.equal(classify(15000)[0], 'enterprise-user');
  assert.equal(classify(12500)[0], 'app-installation');
});

test('sixty is the anonymous tier and not a budget problem', () => {
  assert.equal(classify(60)[0], 'anonymous');
  assert.equal(verdict('anonymous', plan(4, 100))[0], 'unauthenticated');
});

test('an unreadable ceiling does not become a number', () => {
  assert.equal(classify(null)[0], 'unknown');
  assert.equal(classify('plenty')[1], 'none');
});

test('the matrix multiplies the job count', () => {
  const costing = plan(4, 120, 3);
  assert.equal(costing.legs, 3);
  assert.equal(costing.jobs, 12);
  assert.equal(costing.total, 1440);
});

test('an overrun names the first job that starves', () => {
  const costing = plan(12, 120, 1, 1000);
  assert.equal(costing.fits, false);
  assert.equal(costing.jobs_served, 8);
  assert.equal(costing.first_starved_job, 9);
  assert.equal(costing.shortfall, 440);
});

test('remaining is used when it is supplied and is labelled', () => {
  const costing = plan(5, 100, 1, 1000, 240);
  assert.equal(costing.source, 'remaining');
  assert.equal(costing.headroom, 240);
  assert.equal(costing.first_starved_job, 3);
});

test('no calls is not a division by zero', () => {
  const costing = plan(6, 0);
  assert.equal(costing.total, 0);
  assert.equal(costing.fits, true);
  assert.equal(costing.first_starved_job, null);
});

test('a described overrun reads as a job number', () => {
  const [state, detail] = verdict('actions-token', plan(12, 120, 1, 1000));
  assert.equal(state, 'pool-overrun');
  assert.match(detail, /Job 9 of 12/);
  assert.match(detail, /whole repository shares/);
});

test('four fifths of the pool is already a finding', () => {
  const [state, detail] = verdict('actions-token', plan(8, 100, 1, 1000));
  assert.equal(state, 'pool-tight');
  assert.match(detail, /four fifths/);
});

test('a run well inside the pool is reported as fitting', () => {
  assert.equal(verdict('actions-token', plan(2, 50, 1, 1000))[0], 'fits');
});

test('costing a laptop credential says so rather than passing', () => {
  const [state, detail] = verdict('user-or-app', plan(12, 120, 1, 5000));
  assert.equal(state, 'different-ceiling');
  assert.match(detail, /from inside the job/);
});

test('nothing described is not a pass either', () => {
  assert.equal(verdict('actions-token', plan(0, 0))[0], 'no-workflow');
});

test('the reset is seconds or nothing', () => {
  assert.equal(poolResetIn(1000, 940), 60);
  assert.equal(poolResetIn(900, 940), 0);
  assert.equal(poolResetIn(null, 940), null);
});

FAQ

Can I raise the 1,000 an hour limit on GITHUB_TOKEN?

Not directly. The ceiling is a property of the credential class, so the way to change it is to change credentials: authenticate as a GitHub App installation, which starts at 5,000 an hour and scales with the repositories and users the installation covers. On GitHub Enterprise Cloud the built-in token gets 15,000 instead of 1,000, which is why the same workflow can behave differently between two organizations with identical YAML.

Does each job in a matrix get its own 1,000?

No, and this is the single most expensive misunderstanding in the note. The pool is scoped to the repository. Twelve matrix legs at 120 calls each is one budget of 1,440 requests, not twelve budgets of 120. Adding concurrency spends the allowance faster; it never enlarges it.

Why does GET /user return 403 with the Actions token?

Because the credential does not represent a user. It is scoped to the repository the workflow is running in, and the user endpoint has no user to answer with. That refusal is useful rather than annoying: it is a fingerprint no personal access token produces, so it corroborates the 1,000 reading from a completely independent direction.

Does GET /rate_limit itself use up part of the 1,000?

No. The rate limit endpoint is explicitly exempt from the primary rate limit, which is what makes it safe to call at the top of every job and in a loop while you are investigating. It is the one request you never have to budget for.

The failure moves between jobs on every rerun. Is that the same problem?

Almost certainly yes, and the movement is the diagnosis rather than a complication. A per-job quota would fail deterministically in the same job. A shared pool fails in whichever job happens to be holding the bag when the total crosses the ceiling, so the identity of the failing job is decided by scheduling. If the failing job moves and the total call count does not, you are looking at a shared budget.

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.