Diagnostic GitHub API
core REST quota is exhausted and every call returns 403
Every endpoint fails at once, with the same status, in the same second. That pattern says outage or bad credentials, and it is neither: the hourly bucket is empty, and an empty bucket refuses everything equally. The awkward part is that by the time you are reading the 403 the interesting question has already gone past. Not are we out, but at what rate did we spend it, and would that rate have fitted.
GET /rate_limit answers it, and it is the only endpoint in the API that does not count against the limit it reports. Read resources.core: limit, used, remaining and reset. A remaining of 0 with a reset in the future is the whole diagnosis, and the wait is reset minus now.
The useful version of the check runs before that. The core window is a fixed hour, so used divided by the time elapsed since the window opened is your average drain per minute, and remaining divided by the minutes left is the rate you can still afford. When the first number is larger than the second you are going to run out, and the script can name the minute.
The problem in plain words
The failure is total, which misleads. A permissions problem breaks one endpoint; a bad token breaks authenticated calls and leaves public ones working; an outage has a status page. An exhausted bucket breaks every non-search REST call for the same token, everywhere, instantly, and then fixes itself an hour later without anyone touching anything. Half the time the incident is closed as "transient" and recurs the next day at the same hour.
The message names the account, not the process: "API rate limit exceeded for user ID 12345." That is a genuine dead end, because the bucket is per token and shared by everything holding it. The nightly sync, the dashboard that refreshes every thirty seconds, the bot, and the developer running a script by hand are all drawing on one 5,000. The API reports the drain and never says who caused it.
And the drain is rarely steady. A job that fires 3,000 requests in four minutes and then idles looks identical, an hour later, to a job that spent 3,000 evenly. They need opposite repairs, and the only way to tell them apart is to look while it is happening.
Why it happens
The window is fixed, not sliding. The core bucket refills in full at reset, an epoch second that stays put for the whole hour. That is what makes the forecast possible: reset minus 3,600 is when the window opened, so the elapsed time is known, so used / elapsed is a real rate rather than a guess.
The measurement is free. GET /rate_limit is documented as not counting against the primary rate limit. You can poll it every ten seconds during an incident without making the incident worse, which is not true of any other diagnostic in this section.
Average drain and current drain are different numbers. Forty minutes into a window, used at 4,000 gives an average of 100 a minute. If the last two of those minutes spent nothing, you are idle and fine. If they spent 400, you have four minutes left, not twenty. One sample gives you the average; two samples a minute apart give you the rate you are actually running at, and the gap between them is the diagnosis.
The limit is per token, not per process or per IP. Authenticated users get 5,000 an hour, 15,000 on Enterprise Cloud, and a GitHub App installation scales with installed repositories and users up to 12,500. Splitting a workload across four machines that share one token splits nothing.
Not every 403 is this. A refusal with x-ratelimit-remaining still in the thousands is a secondary limit, which is a different mechanism with a different repair. Check the number before you accept the story the message tells.
The fix, as a flow
The script asks the one endpoint that does not charge for asking, then does arithmetic on three numbers. The window is a fixed hour, so reset minus 3,600 is when it opened, which turns a counter into a rate and a rate into a time of death.
How to fix it
Ask the one endpoint that does not charge you for asking
GET /rate_limit returns every bucket the token has: core, search, graphql, code_search and the rest. Only core is the one that breaks ordinary REST calls. Read used, limit and reset from it, and ignore the deprecated top-level rate field, which mirrors core and exists only for compatibility.
Turn reset into an elapsed time
The window is an hour, so it opened at reset - 3600. Elapsed is now minus that. This is the step everyone skips, and it is the one that converts a static counter into a rate: 2,400 used means nothing until you know whether it took fifty minutes or five.
Compare the drain against what you can still afford
Two rates, both per minute. Drain is used / elapsed_minutes. Affordable is remaining / minutes_left. If drain is under affordable you finish the hour with quota to spare; if it is over, divide remaining by drain and you have the number of minutes until the bucket is empty.
Take a second sample to catch a spike
Sample used again thirty or sixty seconds later. The difference over the gap is the drain right now, and it is the number to act on. If reset changed between the two samples the window rolled over and the counter went back to nearly zero, so report that rather than a nonsense negative rate.
Spend less rather than waiting better
Waiting for reset is not a fix, it is a delay. The repairs that hold are conditional requests, because a 304 does not count at all; one GraphQL query in place of fifty REST calls, because GraphQL bills to a separate bucket; a webhook instead of a poll, because then GitHub tells you; and, when the workload is genuinely that big, a GitHub App installation token whose limit scales.
How to check it worked
Run it again with a watch interval and confirm the drain sits under the affordable rate for the rest of the window.
python3 github_quota_forecast.py --watch 30
# clear: drain 41/min against 78/min affordable, 3,102 left with 40 min to reset
The full code
Three pure functions do the work and none of them touch the network: one turns a single /rate_limit body into an average drain and a projection, one turns two samples into the drain right now, and one turns both into a verdict. The network layer is a single GET that costs nothing. Splitting it this way is not tidiness — an exhausted bucket is inconvenient to reproduce on demand, and every interesting case here is one you want to test without waiting an hour for it.
"""Forecast when the core REST bucket empties, from three published numbers.
Read only. Every request is a GET, and GET /rate_limit is documented as not
counting against the primary rate limit, so this never spends what it measures.
The forecast is the point. A bucket that is already empty needs no analysis,
only a clock. The question worth asking is whether the drain running right now
fits inside the window that is left, and that is arithmetic over used, limit
and reset.
"""
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_quota_forecast")
API = "https://api.github.com"
UA = "github-quota-forecast/1.0"
# The core bucket is a fixed one-hour window that refills in full at `reset`,
# which is what makes the elapsed time knowable from a single sample.
WINDOW = 3600.0
def window_burn(used, limit, reset, now, window=WINDOW):
"""Average drain since this window opened, and where it lands. Pure.
reset is an epoch second and the window is fixed, so the window opened at
reset - window. That is the whole trick: it converts a counter, which says
nothing on its own, into a rate. 2,400 used is comfortable at minute fifty
and an emergency at minute five.
"""
try:
used = max(0, int(used))
limit = max(1, int(limit))
left = float(reset) - float(now)
except (TypeError, ValueError):
return None
# A reset further away than the window itself means the clocks disagree.
# Clamping is honest here: it makes elapsed small, which makes the drain
# look high, which is the safe direction to be wrong in.
left = min(max(left, 0.0), window)
elapsed = max(1.0, window - left)
remaining = max(0, limit - used)
per_min = used / (elapsed / 60.0)
left_min = left / 60.0
projected = used + per_min * left_min
# What you may still spend per minute and finish the window on zero.
affordable = remaining / left_min if left_min > 0 else float(remaining)
if remaining <= 0:
empty_in = 0.0
elif per_min <= 0:
empty_in = None
else:
empty_in = remaining / (per_min / 60.0)
if empty_in > left:
empty_in = None # the window refills first
return {"used": used, "limit": limit, "remaining": remaining,
"elapsed": round(elapsed, 1), "left": round(left, 1),
"per_min": round(per_min, 2), "affordable": round(affordable, 2),
"projected": round(projected), "empty_in": empty_in}
def sample_burn(first, second):
"""Drain between two samples of the same bucket. Pure.
Returns (state, per_min). The average over the window is history; this is
the rate right now, and the two disagree exactly when it matters, which is
when a job burst and stopped or is bursting and has not stopped.
A window that rolled between the samples resets `used` to nearly zero, so
the difference goes negative. That is not a negative drain, it is a refill,
and reporting it as "rolled" beats reporting it as a rate.
"""
if not first or not second:
return ("single", None)
try:
u1, r1, t1 = int(first["used"]), float(first["reset"]), float(first["at"])
u2, r2, t2 = int(second["used"]), float(second["reset"]), float(second["at"])
except (KeyError, TypeError, ValueError):
return ("single", None)
gap = t2 - t1
if gap <= 0:
return ("no-gap", None)
if r2 != r1 or u2 < u1:
return ("rolled", None)
return ("measured", round((u2 - u1) / (gap / 60.0), 2))
def verdict(win, instant=("single", None), tight=0.8):
"""Turn the arithmetic into one finding. Pure.
Prefers the measured drain over the window average when there is one,
because the average is a claim about the past and the measurement is a
claim about now.
"""
if not win:
return ("unreadable", "the rate-limit body did not contain usable numbers")
state, measured = instant
drain = measured if (state == "measured" and measured is not None) else win["per_min"]
source = ("measured over the sample gap" if state == "measured"
else "averaged over the window so far")
mins = win["left"] / 60.0
if win["remaining"] <= 0:
return ("exhausted",
"0 of %d left. Every non-search REST call refuses until reset, "
"in %d second(s). Waiting is not the repair, spending less is."
% (win["limit"], int(win["left"])))
if drain > win["affordable"] and drain > 0:
empty = win["remaining"] / (drain / 60.0)
return ("will-exhaust",
"drain is %.1f/min (%s) against %.1f/min affordable. %d left "
"empties in about %d minute(s), %d minute(s) before reset."
% (drain, source, win["affordable"], win["remaining"],
round(empty / 60.0), max(0, round(mins - empty / 60.0))))
if (state == "measured" and measured is not None
and win["per_min"] > 0 and measured > win["per_min"] * 2):
return ("spiky",
"drain is %.1f/min right now against a %.1f/min average for the "
"window. The bucket fits it today, but the average is hiding a "
"burst and a longer burst will not fit."
% (measured, win["per_min"]))
if win["used"] >= win["limit"] * tight:
return ("tight",
"%d of %d used with %d minute(s) to reset. The current drain of "
"%.1f/min fits, but there is no room for a second consumer on "
"this token." % (win["used"], win["limit"], round(mins), drain))
return ("clear",
"drain %.1f/min against %.1f/min affordable, %d left with %d "
"minute(s) to reset."
% (drain, win["affordable"], win["remaining"], round(mins)))
def sample(session):
"""One free GET of the whole rate-limit document."""
r = session.get(API + "/rate_limit", timeout=30)
if r.status_code != 200:
log.error("GET /rate_limit returned %d: %s", r.status_code, r.text[:200])
return None
body = r.json()
return {"resources": body.get("resources", {}), "at": time.time()}
def bucket(snapshot, name):
"""Pull one named bucket out of a snapshot as a flat dict."""
b = (snapshot or {}).get("resources", {}).get(name) or {}
return {"used": b.get("used", 0), "limit": b.get("limit", 0),
"reset": b.get("reset", 0), "remaining": b.get("remaining", 0),
"at": (snapshot or {}).get("at", 0)}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--resource", default="core",
help="which bucket to forecast (default core)")
ap.add_argument("--watch", type=int, default=0, metavar="SECONDS",
help="take a second sample after this many seconds to "
"measure the drain right now (0 = one sample only)")
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",
"User-Agent": UA,
})
first = sample(session)
if first is None:
return 2
for name, b in sorted(first["resources"].items()):
log.info("bucket %-22s %5s / %-6s remaining %s",
name, b.get("used"), b.get("limit"), b.get("remaining"))
second = None
if args.watch > 0:
log.info("second sample in %d second(s)", args.watch)
time.sleep(args.watch)
second = sample(session)
b1 = bucket(first, args.resource)
b2 = bucket(second, args.resource) if second else None
win = window_burn(b1["used"], b1["limit"], b1["reset"], first["at"])
instant = sample_burn(b1, b2)
state, detail = verdict(win, instant)
if instant[0] == "rolled":
log.info("the window rolled between samples: the bucket refilled, so "
"there is no drain to measure across that gap")
log.info("%s: %s", state, detail)
if state in ("exhausted", "will-exhaust", "tight", "spiky"):
log.info("repair: send If-None-Match with the etag you already got "
"back. A 304 Not Modified does not count against this bucket "
"at all, so unchanged data becomes free.")
log.info("repair: replace per-item REST reads with one GraphQL query. "
"GraphQL is billed to a separate bucket, so moving work there "
"removes it from this one twice over.")
log.info("repair: stop polling for changes and subscribe to a webhook, "
"so the change arrives instead of being asked for every "
"thirty seconds by every consumer of this token.")
log.info("repair: if the workload is genuinely this large, "
"authenticate as a GitHub App installation. That limit scales "
"with installed repositories and users, up to 12,500 an hour.")
print(json.dumps({"resource": args.resource, "state": state,
"window": win, "instant": {"state": instant[0],
"per_min": instant[1]}},
indent=2))
return 1 if state in ("exhausted", "will-exhaust") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Forecast when the core REST bucket empties, from three published numbers.
*
* Read only. Every request is a GET, and GET /rate_limit does not count
* against the primary rate limit, so this never spends what it measures.
*/
const API = 'https://api.github.com';
const UA = 'github-quota-forecast/1.0';
// The core bucket is a fixed one-hour window that refills in full at `reset`.
export const WINDOW = 3600;
/**
* Average drain since this window opened, and where it lands. Pure.
* reset - WINDOW is when the window opened, which turns a counter into a rate.
*/
export function windowBurn(used, limit, reset, now, window = WINDOW) {
const u = Number.parseInt(used, 10);
const l = Number.parseInt(limit, 10);
const r = Number(reset);
const t = Number(now);
if (!Number.isFinite(u) || !Number.isFinite(l) || !Number.isFinite(r) || !Number.isFinite(t)) {
return null;
}
const usedN = Math.max(0, u);
const limitN = Math.max(1, l);
// Clamped: a reset further away than the window means the clocks disagree,
// and erring towards a high drain is the safe direction.
const left = Math.min(Math.max(r - t, 0), window);
const elapsed = Math.max(1, window - left);
const remaining = Math.max(0, limitN - usedN);
const perMin = usedN / (elapsed / 60);
const leftMin = left / 60;
const projected = usedN + perMin * leftMin;
const affordable = leftMin > 0 ? remaining / leftMin : remaining;
let emptyIn;
if (remaining <= 0) emptyIn = 0;
else if (perMin <= 0) emptyIn = null;
else {
const secs = remaining / (perMin / 60);
emptyIn = secs > left ? null : secs;
}
return {
used: usedN, limit: limitN, remaining,
elapsed: Math.round(elapsed * 10) / 10,
left: Math.round(left * 10) / 10,
per_min: Math.round(perMin * 100) / 100,
affordable: Math.round(affordable * 100) / 100,
projected: Math.round(projected),
empty_in: emptyIn,
};
}
/**
* Drain between two samples of the same bucket. Pure.
* A window that rolled resets `used`, so the difference goes negative. That is
* a refill, not a negative rate, and it is reported as one.
*/
export function sampleBurn(first, second) {
if (!first || !second) return ['single', null];
const u1 = Number.parseInt(first.used, 10);
const u2 = Number.parseInt(second.used, 10);
const r1 = Number(first.reset);
const r2 = Number(second.reset);
const t1 = Number(first.at);
const t2 = Number(second.at);
if (![u1, u2, r1, r2, t1, t2].every(Number.isFinite)) return ['single', null];
const gap = t2 - t1;
if (gap <= 0) return ['no-gap', null];
if (r2 !== r1 || u2 < u1) return ['rolled', null];
return ['measured', Math.round(((u2 - u1) / (gap / 60)) * 100) / 100];
}
/**
* Turn the arithmetic into one finding. Pure.
* Prefers the measured drain: the average is a claim about the past.
*/
export function verdict(win, instant = ['single', null], tight = 0.8) {
if (!win) return ['unreadable', 'the rate-limit body did not contain usable numbers'];
const [state, measured] = instant;
const drain = (state === 'measured' && measured !== null) ? measured : win.per_min;
const source = state === 'measured'
? 'measured over the sample gap'
: 'averaged over the window so far';
const mins = win.left / 60;
if (win.remaining <= 0) {
return ['exhausted',
`0 of ${win.limit} left. Every non-search REST call refuses until reset, ` +
`in ${Math.trunc(win.left)} second(s). Waiting is not the repair, ` +
'spending less is.'];
}
if (drain > win.affordable && drain > 0) {
const empty = win.remaining / (drain / 60);
return ['will-exhaust',
`drain is ${drain.toFixed(1)}/min (${source}) against ` +
`${win.affordable.toFixed(1)}/min affordable. ${win.remaining} left ` +
`empties in about ${Math.round(empty / 60)} minute(s), ` +
`${Math.max(0, Math.round(mins - empty / 60))} minute(s) before reset.`];
}
if (state === 'measured' && measured !== null && win.per_min > 0
&& measured > win.per_min * 2) {
return ['spiky',
`drain is ${measured.toFixed(1)}/min right now against a ` +
`${win.per_min.toFixed(1)}/min average for the window. The bucket fits ` +
'it today, but the average is hiding a burst and a longer burst will not fit.'];
}
if (win.used >= win.limit * tight) {
return ['tight',
`${win.used} of ${win.limit} used with ${Math.round(mins)} minute(s) to ` +
`reset. The current drain of ${drain.toFixed(1)}/min fits, but there is ` +
'no room for a second consumer on this token.'];
}
return ['clear',
`drain ${drain.toFixed(1)}/min against ${win.affordable.toFixed(1)}/min ` +
`affordable, ${win.remaining} left with ${Math.round(mins)} minute(s) to reset.`];
}
async function sample(token) {
const res = await fetch(`${API}/rate_limit`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
},
});
if (res.status !== 200) {
console.error(`GET /rate_limit returned ${res.status}: ${(await res.text()).slice(0, 200)}`);
return null;
}
const body = await res.json();
return { resources: body.resources ?? {}, at: Date.now() / 1000 };
}
const bucket = (snapshot, name) => {
const b = snapshot?.resources?.[name] ?? {};
return {
used: b.used ?? 0, limit: b.limit ?? 0,
reset: b.reset ?? 0, remaining: b.remaining ?? 0,
at: snapshot?.at ?? 0,
};
};
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 resource = process.argv[2] ?? 'core';
const watch = Math.max(0, Number.parseInt(process.argv[3] ?? '0', 10) || 0);
const first = await sample(token);
if (!first) { process.exitCode = 2; return; }
for (const [name, b] of Object.entries(first.resources).sort()) {
console.log(`bucket ${name.padEnd(22)} ${b.used} / ${b.limit} remaining ${b.remaining}`);
}
let second = null;
if (watch > 0) {
console.log(`second sample in ${watch} second(s)`);
await new Promise((r) => { setTimeout(r, watch * 1000); });
second = await sample(token);
}
const b1 = bucket(first, resource);
const b2 = second ? bucket(second, resource) : null;
const win = windowBurn(b1.used, b1.limit, b1.reset, first.at);
const instant = sampleBurn(b1, b2);
const [state, detail] = verdict(win, instant);
if (instant[0] === 'rolled') {
console.log('the window rolled between samples: the bucket refilled, so ' +
'there is no drain to measure across that gap');
}
console.log(`${state}: ${detail}`);
if (['exhausted', 'will-exhaust', 'tight', 'spiky'].includes(state)) {
console.log('repair: send If-None-Match with the etag you already got back. ' +
'A 304 Not Modified does not count against this bucket at all.');
console.log('repair: replace per-item REST reads with one GraphQL query, ' +
'which is billed to a separate bucket entirely.');
console.log('repair: stop polling and subscribe to a webhook, so the change ' +
'arrives instead of being asked for every thirty seconds.');
console.log('repair: for a genuinely large workload, authenticate as a ' +
'GitHub App installation, whose limit scales up to 12,500 an hour.');
}
console.log(JSON.stringify({
resource, state, window: win,
instant: { state: instant[0], per_min: instant[1] },
}, null, 2));
process.exitCode = (state === 'exhausted' || state === 'will-exhaust') ? 1 : 0;
}
// Only run when invoked directly, so importing this from the test file does not
// start main(), fail on the missing token and set a non-zero exit code.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The cases worth pinning are the ones where a plausible implementation quietly returns nonsense: a window that rolled between the two samples, so used went backwards and a naive subtraction reports a negative drain; a reset that is further away than the window itself, which is what a skewed clock looks like; and the minute after a window opens, when elapsed time is nearly zero and dividing by it produces an infinite rate. Each of those is a real reading from a real API and none of them is an error.
from github_quota_forecast import window_burn, sample_burn, verdict
NOW = 1_800_000_000.0
def at_minute(minute, used, limit=5000):
"""A window that opened `minute` minutes ago with `used` spent."""
reset = NOW + (3600 - minute * 60)
return window_burn(used, limit, reset, NOW)
def test_used_alone_says_nothing_until_it_is_a_rate():
early = at_minute(5, 2400)
late = at_minute(50, 2400)
assert early["per_min"] > late["per_min"] * 5
assert early["remaining"] == late["remaining"] == 2600
def test_a_steady_drain_that_fits_leaves_the_window_intact():
win = at_minute(30, 1500)
assert win["per_min"] == 50.0
assert win["affordable"] == round(3500 / 30.0, 2)
assert win["empty_in"] is None
def test_a_drain_that_does_not_fit_names_the_minute():
win = at_minute(30, 4000)
assert win["per_min"] > win["affordable"]
assert win["empty_in"] is not None
assert 400 < win["empty_in"] < 500
def test_an_empty_bucket_empties_in_zero_seconds():
win = at_minute(45, 5000)
assert win["remaining"] == 0
assert win["empty_in"] == 0.0
def test_the_first_minute_does_not_divide_by_zero():
win = window_burn(3, 5000, NOW + 3600, NOW)
assert win["elapsed"] == 1.0
assert win["per_min"] == 180.0
def test_a_reset_beyond_the_window_is_clamped_rather_than_trusted():
# A skewed clock. Clamping makes elapsed small and the drain look high,
# which is the safe direction to be wrong in.
win = window_burn(100, 5000, NOW + 9000, NOW)
assert win["left"] == 3600.0
assert win["elapsed"] == 1.0
def test_unusable_numbers_return_nothing_rather_than_a_guess():
assert window_burn(None, 5000, NOW, NOW) is None
assert window_burn("many", 5000, NOW, NOW) is None
def test_two_samples_measure_the_drain_right_now():
first = {"used": 1000, "reset": NOW + 1800, "at": NOW}
second = {"used": 1030, "reset": NOW + 1800, "at": NOW + 30}
assert sample_burn(first, second) == ("measured", 60.0)
def test_a_rolled_window_is_a_refill_not_a_negative_drain():
first = {"used": 4900, "reset": NOW + 10, "at": NOW}
second = {"used": 12, "reset": NOW + 3610, "at": NOW + 30}
assert sample_burn(first, second) == ("rolled", None)
def test_one_sample_is_reported_as_one_sample():
assert sample_burn({"used": 1, "reset": NOW, "at": NOW}, None) == ("single", None)
assert sample_burn(None, None)[0] == "single"
def test_two_samples_at_the_same_instant_measure_nothing():
s = {"used": 10, "reset": NOW + 60, "at": NOW}
assert sample_burn(s, dict(s, used=20)) == ("no-gap", None)
def test_exhausted_reports_the_wait_and_refuses_to_call_it_a_fix():
state, detail = verdict(at_minute(45, 5000))
assert state == "exhausted"
assert "900 second(s)" in detail
assert "Waiting is not the repair" in detail
def test_a_measured_spike_overrides_a_comfortable_average():
win = at_minute(50, 1000) # a 20/min average with 4,000 still in the bucket
state, detail = verdict(win, ("measured", 600.0))
assert state == "will-exhaust"
assert "measured over the sample gap" in detail
def test_a_burst_that_still_fits_is_flagged_as_spiky_not_safe():
win = at_minute(10, 200) # 20/min average, 4800 left over 50 minutes
state, _ = verdict(win, ("measured", 60.0))
assert state == "spiky"
def test_eighty_percent_used_is_tight_even_when_the_drain_fits():
win = at_minute(55, 4100)
state, detail = verdict(win, ("measured", 1.0))
assert state == "tight"
assert "second consumer" in detail
def test_a_healthy_window_is_clear():
state, detail = verdict(at_minute(30, 900), ("measured", 30.0))
assert state == "clear"
assert "4100 left" in detail
def test_an_unreadable_body_is_not_reported_as_healthy():
assert verdict(None)[0] == "unreadable"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { windowBurn, sampleBurn, verdict } from './github-quota-forecast.mjs';
const NOW = 1800000000;
/** A window that opened `minute` minutes ago with `used` spent. */
const atMinute = (minute, used, limit = 5000) =>
windowBurn(used, limit, NOW + (3600 - minute * 60), NOW);
test('used alone says nothing until it is a rate', () => {
const early = atMinute(5, 2400);
const late = atMinute(50, 2400);
assert.ok(early.per_min > late.per_min * 5);
assert.equal(early.remaining, 2600);
assert.equal(late.remaining, 2600);
});
test('a steady drain that fits leaves the window intact', () => {
const win = atMinute(30, 1500);
assert.equal(win.per_min, 50);
assert.equal(win.affordable, Math.round((3500 / 30) * 100) / 100);
assert.equal(win.empty_in, null);
});
test('a drain that does not fit names the minute', () => {
const win = atMinute(30, 4000);
assert.ok(win.per_min > win.affordable);
assert.ok(win.empty_in > 400 && win.empty_in < 500);
});
test('an empty bucket empties in zero seconds', () => {
const win = atMinute(45, 5000);
assert.equal(win.remaining, 0);
assert.equal(win.empty_in, 0);
});
test('the first minute does not divide by zero', () => {
const win = windowBurn(3, 5000, NOW + 3600, NOW);
assert.equal(win.elapsed, 1);
assert.equal(win.per_min, 180);
});
test('a reset beyond the window is clamped rather than trusted', () => {
const win = windowBurn(100, 5000, NOW + 9000, NOW);
assert.equal(win.left, 3600);
assert.equal(win.elapsed, 1);
});
test('unusable numbers return nothing rather than a guess', () => {
assert.equal(windowBurn(null, 5000, NOW, NOW), null);
assert.equal(windowBurn('many', 5000, NOW, NOW), null);
});
test('two samples measure the drain right now', () => {
const first = { used: 1000, reset: NOW + 1800, at: NOW };
const second = { used: 1030, reset: NOW + 1800, at: NOW + 30 };
assert.deepEqual(sampleBurn(first, second), ['measured', 60]);
});
test('a rolled window is a refill, not a negative drain', () => {
const first = { used: 4900, reset: NOW + 10, at: NOW };
const second = { used: 12, reset: NOW + 3610, at: NOW + 30 };
assert.deepEqual(sampleBurn(first, second), ['rolled', null]);
});
test('one sample is reported as one sample', () => {
assert.deepEqual(sampleBurn({ used: 1, reset: NOW, at: NOW }, null), ['single', null]);
assert.equal(sampleBurn(null, null)[0], 'single');
});
test('two samples at the same instant measure nothing', () => {
const s = { used: 10, reset: NOW + 60, at: NOW };
assert.deepEqual(sampleBurn(s, { ...s, used: 20 }), ['no-gap', null]);
});
test('exhausted reports the wait and refuses to call it a fix', () => {
const [state, detail] = verdict(atMinute(45, 5000));
assert.equal(state, 'exhausted');
assert.match(detail, /900 second\(s\)/);
assert.match(detail, /Waiting is not the repair/);
});
test('a measured spike overrides a comfortable average', () => {
const [state, detail] = verdict(atMinute(50, 1000), ['measured', 600]);
assert.equal(state, 'will-exhaust');
assert.match(detail, /measured over the sample gap/);
});
test('a burst that still fits is flagged as spiky, not safe', () => {
assert.equal(verdict(atMinute(10, 200), ['measured', 60])[0], 'spiky');
});
test('eighty percent used is tight even when the drain fits', () => {
const [state, detail] = verdict(atMinute(55, 4100), ['measured', 1]);
assert.equal(state, 'tight');
assert.match(detail, /second consumer/);
});
test('a healthy window is clear', () => {
const [state, detail] = verdict(atMinute(30, 900), ['measured', 30]);
assert.equal(state, 'clear');
assert.match(detail, /4100 left/);
});
test('an unreadable body is not reported as healthy', () => {
assert.equal(verdict(null)[0], 'unreadable');
});
FAQ
Does calling GET /rate_limit use up part of my rate limit?
No. It is documented as not counting against the primary rate limit, which is what makes it usable as a monitor rather than only as a post-mortem. You can poll it every ten seconds during an incident without making the incident worse. It is the only diagnostic in this section with that property, and it is the reason this script defaults to it rather than probing a real endpoint.
Why does the error name a user ID instead of my script?
Because the bucket belongs to the token, not to the process holding it. Every job, dashboard, bot and hand-run script authenticating with that token draws on the same 5,000 an hour, and the API reports the total drain without ever attributing it. That is a genuine blind spot: if you need to know which consumer spent the quota, you have to instrument the consumers, because GitHub will not tell you.
Will splitting the work across more machines help?
Not if they share a token, which is the usual arrangement. The limit is per token, so four workers with one token have 5,000 between them, exactly as one worker did. Separate tokens do give separate buckets, but issuing a token per worker to dodge a limit is the kind of fix that becomes a security review later. Fewer requests is the durable version.
Is 403 always the rate limit?
No, and the check that separates them takes one second. If x-ratelimit-remaining on the refused response is 0, it is this problem. If it is still in the thousands, the refusal came from somewhere else: a secondary rate limit, which throttles bursts rather than volume, or a permissions error, which GitHub also returns as 403 and sometimes as 404. Read the number before you accept the message.
How much headroom should I aim to keep?
Enough that an unplanned consumer does not take you out. If the token is shared, treat 80 percent used as the ceiling rather than the target, because the remaining 20 percent is what absorbs the developer who runs a backfill by hand at four in the afternoon. If the token is genuinely single-purpose you can run closer to the line, but then the forecast matters more, not less.
Related field notes
- Polling without ETags spends full quota
- per_page is unset so every list costs more
- Ignoring retry-after extends the throttle
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.
- Rate limits for the REST API — GitHub Docs
- Rate limit — GitHub REST API
- Best practices for using the REST API — GitHub Docs
- Rate limits for GitHub Apps — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.