Diagnostic GitHub API
Nobody measured what the GraphQL query actually costs
The dashboard query has been running every fifteen seconds for a year. Last Tuesday somebody added one nested field to it, the review was two lines long and entirely reasonable, and on Thursday afternoon the whole integration started returning RATE_LIMITED at about ten past two. Nothing in the pull request said the price had gone from three points to fourteen, because nobody had ever written down that it was three.
Ask the server. Adding rateLimit { cost nodeCount remaining } to a query makes every response report its own price, and that price is the only authoritative number: the cost is derived from how many connections the query could traverse and how many items each was asked for, not from how much data came back. A query returning twelve rows can legitimately cost fourteen points.
Then compare that measurement against two other numbers. The first is what the query text predicts, which is roughly the sum of the first and last values divided by 100 — if the server charges several times that, something in the document is traversing more than it looks like it does. The second is what it cost last time. Recording a cost per query shape and alerting when a deploy changes it is the whole discipline, and it is what turns a mid-afternoon outage into a line in a diff.
The problem in plain words
The reason nobody measures it is that the first version is cheap and works. A GraphQL query written to replace a hundred REST calls costs one or two points, comes back fast, and drops the REST quota problem off the board entirely. That is a success, and successes do not get instrumented. The cost field exists, it is documented, and there is no reason to add it to a query that is obviously fine.
Then the query grows the way every useful query grows: one field at a time, each added by somebody solving a real problem, each reviewed on its own. A nested connection here, a wider first there, a second connection alongside the first because the dashboard needed one more column. Every one of those diffs is small and none of them mentions points, because the price is not written anywhere for the diff to change.
What makes the eventual failure so confusing is the timing. The quota does not degrade; it holds up perfectly until a threshold, and then every call fails for the rest of the hour and works again after the reset. So it looks like an incident rather than a change, and the search goes to infrastructure, deploys, GitHub status pages and concurrency, when the actual cause is a field added on Tuesday to a query nobody had priced.
Why it happens
Cost is a property of the request, not of the response. GitHub computes it from the number of connections the query could traverse and the slice each one asked for. A query that asks for a hundred items and finds three still asked for a hundred. This is why "it only returns a few rows" is never an argument about price, and why the cheapest way to make a query cheaper is almost always to lower a first rather than to filter harder.
The server will tell you, in band, for free. rateLimit { cost nodeCount limit remaining resetAt } can be added to a query you were already sending, and it comes back in the same response with no extra round trip. That makes the measurement essentially free once, and continuous if you log it. There is no server-side history of your query costs to consult afterwards, so if you do not record it, it is gone.
Predicting from the text is useful precisely where it is wrong. The documented approximation is the sum of the first and last values divided by 100, rounded, with a minimum of one. When the server agrees, the document says what it does. When the server charges several times more, the query is traversing something the arithmetic did not see, and that disagreement is worth more than either number on its own.
Cost drift is a code review problem, not an operations problem. The change that breaks the budget arrives in a pull request, not in an alert, and it arrives weeks before the failure. A recorded cost per query shape turns that into a reviewable fact: this query was 3, this diff makes it 14, is that intended. Without the record there is nothing for a reviewer to react to.
This is not the budget, and it is not the node limit. How many points you have left, which bucket they came from and how fast you may spend them is a separate note that owns the budget. Whether the query is so large it will be rejected before it runs is a third note that owns the node ceiling. This one owns a narrower question: what does this particular query shape cost, and is that what its author believes. A query can be comfortably inside both limits and still be four times the price anybody thinks it is.
The measurement costs one point and is only about this shape. Sending the query once to measure it spends a point, which the script prints before it spends. And the number belongs to the shape rather than to the target: pointing the same document at a larger organisation does not change the price, which is the whole reason a baseline recorded once stays useful.
The fix, as a flow
Three numbers for one query shape. The prediction is free and comes out of the text, using the documented approximation over the slices it can resolve; the measurement costs one point and comes from the server itself, after rateLimit is inserted into the document's top-level selection set and only there, which is why the comments and strings are blanked to spaces rather than removed and every index still lines up with the original. The third number is whatever was recorded last time. What the run reports is the disagreement between them, and the baseline file is printed for you to update rather than rewritten.
How to fix it
Predict the cost from the query text before you send it
The script sums the first and last values it can resolve, divides by 100 and prints the result with the arguments that produced it. This is free and it is a prediction rather than an answer. Its value is as something for the measurement to disagree with.
Inject rateLimit and measure the real price once
The script inserts rateLimit { cost nodeCount limit remaining resetAt } into the document's top-level selection set and sends it, which costs one point and returns the server's own number. If the document already asks for it, nothing is changed. This is the authoritative figure and everything else is compared against it.
Compare it against what the caller assumed
Pass --assumed with the number in somebody's head or in the scheduling comment. A query believed to cost 1 and charging 14 is not a tuning opportunity, it is a fourteenfold error in every capacity calculation built on it, and stating the ratio out loud is what gets it fixed.
Compare it against what it cost last time
Point --baseline at a JSON file of recorded costs per query name. The script reports whether this shape has become more expensive since the file was written and by how much. Run it in CI on the queries a repository ships and the price change arrives as a review comment rather than as an outage.
Check the price against the data, then take the budget elsewhere
The script counts the nodes actually returned beside the points charged, because seeing 12 rows for 14 points is what finally kills the idea that cost follows data. It also projects an hourly total for a given call rate. What that total means against your quota belongs to the budget note, and the script says so rather than repeating it.
How to check it worked
Three numbers for one query: what the text predicts, what the server charges, and what somebody assumed.
GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_graphql_cost.py \
--file queries/dashboard.graphql --name dashboard --assumed 1 \
--baseline costs.json --calls-per-hour 240
# point cost: 1 point(s) against the 5,000/hour GraphQL budget
# predicted from the text: 3 point(s) from 4 slicing argument(s) totalling 220
# measured by the server: 14 point(s), nodeCount 3,180
# assumed by the caller: 1 point(s); recorded baseline: 3 point(s)
# cost-increased-since-the-baseline: this shape cost 3 point(s) when the
# baseline was written and costs 14 now, a rise of 367%
# 12 node(s) came back for 14 point(s), so the price is not the size of the answer
# at 240 call(s)/hour this shape needs 3,360 points/hour
# repair: record the new cost against the shape and treat the change as part of
# the diff that caused it. Add this line to costs.json: "dashboard": 14
The full code
Two numbers and their disagreement. The prediction is pure text: blank out the comments and string literals, walk the argument lists, resolve each first and last through literals, variable defaults and the variables map, and apply the documented approximation. The measurement needs one query, so the script performs the surgery that gets it — inserting rateLimit into the document's top-level selection set, and only there, which is why the noise is blanked to spaces rather than removed and the index still lines up with the original text. As everywhere in this section, a document containing a mutation or a subscription is refused before a socket opens.
"""Measure what a GraphQL query costs and compare it with what anybody assumed.
Read only, and queries only. GitHub's GraphQL endpoint takes a document in the
request body, so a read is carried by POST there just as a write would be; that
is transport, not intent. This script sends queries and refuses any document
containing a mutation or a subscription before it opens a socket. Nothing is
written -- including the baseline file, which is printed for you to update
rather than rewritten here.
A query's point cost is computed from the connections it could traverse and the
slice each one asked for, not from the data that came back, so a query returning
a dozen rows can cost more than a dozen points. The server reports the number in
band if you ask for it, and there is no server-side history of what your queries
cost, so a price nobody recorded is a price nobody can compare against.
What this can and cannot see: the measurement is authoritative for the document
and variables handed to it. It cannot see the other shapes your integration
sends, and it cannot attribute the budget's drain to any of them, because the
bucket is shared by every process holding the token.
Environment:
GITHUB_TOKEN a token with read access to the GraphQL API
"""
import argparse
import json
import logging
import os
import sys
from pathlib import Path
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_graphql_cost")
API = "https://api.github.com"
UA = "github-graphql-cost/1.0"
POINTS_PER_QUERY = 1
# The selection that makes a response report its own price. Cheap to carry: it
# adds no round trip and the response is one object longer.
RATE_LIMIT_SELECTION = "rateLimit { cost nodeCount limit remaining resetAt }"
DEFAULT_QUERY = (
"query($login: String!) {"
" repositoryOwner(login: $login) {"
" repositories(first: 50) { totalCount nodes { name"
" issues(first: 20, states: OPEN) { totalCount nodes { number } } } }"
" } }"
)
def blank_noise(document):
"""Comments and string literals replaced by spaces. Pure.
Length preserving, unlike a scanner that removes them, because this one is
used to compute an index back into the original text.
"""
src = str(document or "")
out = list(src)
i, n = 0, len(src)
while i < n:
ch = src[i]
if ch == "#":
while i < n and src[i] != "\n":
out[i] = " "
i += 1
continue
if src.startswith('"""', i):
j = src.find('"""', i + 3)
end = n if j < 0 else j + 3
for k in range(i, end):
out[k] = " "
i = end
continue
if ch == '"':
out[i] = " "
i += 1
while i < n and src[i] != '"':
step = 2 if src[i] == "\\" else 1
for k in range(i, min(n, i + step)):
out[k] = " "
i += step
if i < n:
out[i] = " "
i += 1
continue
i += 1
return "".join(out)
def operations(document):
"""The top-level operations in a document, in order. Pure."""
src = blank_noise(document)
ops, depth, word, declared = [], 0, "", None
for ch in src + " ":
if ch.isalnum() or ch == "_":
word += ch
continue
if word:
if depth == 0 and word in ("query", "mutation", "subscription", "fragment"):
declared = word
word = ""
if ch == "{":
if depth == 0:
ops.append(declared or "query")
declared = None
depth += 1
elif ch == "}":
depth = max(0, depth - 1)
return ops
def refusal(document):
"""Why this document will not be sent, or None if it is a read. Pure."""
ops = operations(document)
if not ops:
return "the document contains no operation to send."
for kind in ("mutation", "subscription"):
if kind in ops:
return ("the document contains a %s. This script sends queries only: "
"a query is a read, and the section it belongs to promises "
"its scripts never write." % kind)
return None
def selection_set_start(document):
"""Index of the operation's opening brace, or -1. Pure.
Braces inside the variable definitions -- an input object used as a default
value -- are skipped, because inserting a selection there would produce a
document that no longer parses.
"""
src = blank_noise(document)
parens = 0
for i, ch in enumerate(src):
if ch == "(":
parens += 1
elif ch == ")":
parens = max(0, parens - 1)
elif ch == "{" and parens == 0:
return i
return -1
def inject_rate_limit(document):
"""The document with rateLimit added to its top-level selection. Pure.
Idempotent: a document that already asks for it comes back untouched, so
running this over a repository of queries does not accumulate duplicates.
"""
src = str(document or "")
if "rateLimit" in blank_noise(src):
return src
at = selection_set_start(src)
if at < 0:
return src
return src[:at + 1] + " " + RATE_LIMIT_SELECTION + src[at + 1:]
def slicing_pairs(argument_text):
"""The first and last arguments in one argument list. Pure.
Splits on top-level commas so a nested input object does not confuse the
scan, and matches the key exactly so a variable definition reading
`$first: Int = 250` is never counted as an argument called first.
"""
src = str(argument_text or "")
parts, depth, cur = [], 0, ""
for ch in src:
if ch in "([{":
depth += 1
elif ch in ")]}":
depth = max(0, depth - 1)
if ch == "," and depth == 0:
parts.append(cur)
cur = ""
continue
cur += ch
parts.append(cur)
out = []
for part in parts:
key, sep, value = part.partition(":")
if sep and key.strip() in ("first", "last"):
out.append((key.strip(), value.strip()))
return out
def variable_defaults(document):
"""Defaults declared in the operation's variable definitions. Pure."""
head = blank_noise(document).split("{", 1)[0]
out = {}
for part in head.replace("(", " ").replace(")", " ").split(","):
name, sep, rest = part.partition(":")
if not sep:
continue
name = (name.strip().rsplit(None, 1) or [""])[-1]
if not name.startswith("$") or "=" not in rest:
continue
out[name] = rest.split("=", 1)[1].strip()
return out
def as_int(value):
"""An integer, or None if this is not one. Pure."""
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def resolve_slice(raw, defaults=None, variables=None):
"""One written slicing value resolved to (value, source). Pure."""
text = str(raw or "").strip()
if not text:
return (None, "missing")
if not text.startswith("$"):
return (as_int(text), "literal")
supplied = variables if isinstance(variables, dict) else {}
if text[1:] in supplied:
return (as_int(supplied[text[1:]]), "variable-supplied")
if text in (defaults or {}):
return (as_int((defaults or {})[text]), "variable-default")
return (None, "unresolved")
def slice_values(document, variables=None):
"""Every first and last in the document, resolved. Pure."""
src = blank_noise(document)
defaults = variable_defaults(document)
out, i, n, word, field = [], 0, len(src), "", ""
while i < n:
ch = src[i]
if ch.isalnum() or ch == "_":
word += ch
i += 1
continue
if word:
field, word = word, ""
if ch == "(":
j, level = i, 0
while j < n:
if src[j] == "(":
level += 1
elif src[j] == ")":
level -= 1
if level == 0:
break
j += 1
for arg, raw in slicing_pairs(src[i + 1:j]):
value, source = resolve_slice(raw, defaults, variables)
out.append({"field": field, "arg": arg, "written": raw,
"value": value, "source": source})
i = j + 1
continue
i += 1
return out
def predicted_cost(document, variables=None):
"""The documented approximation, from the text. Pure.
Returns (points, unresolved). Roughly the sum of the slices divided by 100
with a minimum of one, which is a prediction rather than an answer: its job
is to be something the server's number can disagree with.
"""
values = slice_values(document, variables)
total, unresolved = 0, 0
for v in values:
if isinstance(v["value"], int) and v["value"] > 0:
total += v["value"]
else:
unresolved += 1
return (max(1, -(-total // 100)), unresolved)
def find_rate_limit(body):
"""The rateLimit object anywhere in a response. Pure."""
if isinstance(body, dict):
node = body.get("rateLimit")
if isinstance(node, dict):
return node
for value in body.values():
found = find_rate_limit(value)
if found is not None:
return found
elif isinstance(body, list):
for item in body:
found = find_rate_limit(item)
if found is not None:
return found
return None
def measured_cost(body):
"""What the server charged for this call, or None. Pure."""
node = find_rate_limit(body) or {}
cost = node.get("cost")
return cost if isinstance(cost, int) else None
def measured_nodes(body):
"""The node count the server computed for this call, or None. Pure."""
node = find_rate_limit(body) or {}
count = node.get("nodeCount")
return count if isinstance(count, int) else None
def returned_nodes(body):
"""How many items actually came back in every nodes list. Pure."""
total = 0
if isinstance(body, dict):
for key, value in body.items():
if key in ("nodes", "edges") and isinstance(value, list):
total += len(value)
total += returned_nodes(value)
elif isinstance(body, list):
for item in body:
total += returned_nodes(item)
return total
def gap(predicted, measured):
"""The disagreement between the text and the server. Pure."""
if measured is None:
return (None, "unmeasured")
if not predicted or predicted <= 0:
return (None, "unpredictable")
ratio = measured / float(predicted)
if ratio >= 2:
return (ratio, "far-above-the-text")
if ratio > 1.25:
return (ratio, "above-the-text")
if ratio < 0.75:
return (ratio, "below-the-text")
return (ratio, "close-to-the-text")
def drift(baseline, measured):
"""This shape's price against the recorded one. Pure."""
if not isinstance(baseline, int):
return ("no-baseline",
"no recorded cost for this shape, so nothing can be compared. "
"Record this one and the next change becomes visible.")
if measured is None:
return ("unmeasured", "nothing to compare the baseline against.")
if measured == baseline:
return ("unchanged",
"this shape costs the same %d point(s) it did when the baseline "
"was written." % baseline)
direction = "rise" if measured > baseline else "fall"
percent = abs(measured - baseline) * 100.0 / max(1, baseline)
return ("increased" if measured > baseline else "decreased",
"this shape cost %d point(s) when the baseline was written and costs "
"%d now, a %s of %.0f%%." % (baseline, measured, direction, percent))
def classify(measured, predicted, baseline=None, returned=None):
"""Classify one measurement. Pure. Returns (state, detail)."""
if measured is None:
return ("cost-unmeasured",
"the response carried no rateLimit { cost }, so this run "
"measured nothing. Nothing else here is worth reading.")
drift_state, drift_detail = drift(baseline, measured)
if drift_state == "increased":
return ("cost-increased-since-the-baseline", drift_detail)
ratio, verdict = gap(predicted, measured)
if verdict in ("far-above-the-text", "above-the-text"):
return ("cost-above-the-shape-of-the-query",
"the server charged %d where the document predicted %d, a "
"factor of %.1f." % (measured, predicted, ratio))
if isinstance(returned, int) and measured >= 5 and returned <= measured:
return ("cost-unrelated-to-the-data-returned",
"%d node(s) came back for %d point(s). The price follows what "
"the query asked for, not what it found."
% (returned, measured))
return ("cost-measured",
"this shape costs %d point(s), which is what the document predicts "
"and what the baseline says." % measured)
def repair(state):
"""The sentence a reader has to act on. Pure."""
if state == "cost-increased-since-the-baseline":
return ("record the new cost against the shape and treat the change as "
"part of the diff that caused it. A price change belongs in a "
"code review, not in an incident.")
if state == "cost-above-the-shape-of-the-query":
return ("find what the document traverses that the arithmetic did not "
"see -- usually a connection nested inside another -- and split "
"the query rather than widening the budget.")
if state == "cost-unrelated-to-the-data-returned":
return ("lower the first values rather than filtering harder. Filters "
"change what comes back; only the slice changes the price.")
if state == "cost-unmeasured":
return ("add rateLimit { cost nodeCount remaining } to the query. It "
"costs no extra round trip and there is no other way to learn "
"the number.")
if state == "cost-measured":
return ("record this number so the next change to the query has "
"something to be compared against.")
return "point the check at a document this endpoint can answer."
def points_per_hour(cost, calls_per_hour):
"""What a schedule spends. Pure."""
if not isinstance(cost, int) or not calls_per_hour:
return None
return cost * int(calls_per_hour)
def point_cost(queries):
"""Points this run will spend. Pure."""
return int(queries or 0) * POINTS_PER_QUERY
def run_query(session, document, variables):
"""Send one query. Returns (status, body-or-None).
A GraphQL query is a read; POST is only how the document reaches the
endpoint, which is why the verb is written here beside the URL rather than
tucked into a constant where it could be mistaken for a write path.
"""
r = session.post(API + "/graphql",
json={"query": document, "variables": variables or {}},
timeout=30)
if r.status_code == 401:
raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
"or revoked")
try:
return r.status_code, r.json()
except ValueError:
return r.status_code, None
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--file", help="a .graphql file to price")
ap.add_argument("--query", help="the document as a string")
ap.add_argument("--variables", default="{}", help="JSON object of variables")
ap.add_argument("--login", help="user or organisation for the default query")
ap.add_argument("--name", default="query", help="name for this shape in the baseline")
ap.add_argument("--assumed", type=int, help="the cost somebody believes this has")
ap.add_argument("--baseline", help="JSON file of recorded costs per shape name")
ap.add_argument("--calls-per-hour", type=int, default=0,
help="how often this shape is sent, for an hourly projection")
args = ap.parse_args()
document = Path(args.file).read_text(encoding="utf-8") if args.file \
else (args.query or DEFAULT_QUERY)
try:
variables = json.loads(args.variables)
except ValueError:
log.error("--variables takes a JSON object")
return 2
if not isinstance(variables, dict):
log.error("--variables takes a JSON object")
return 2
if not args.file and not args.query:
if not args.login:
log.error("--login takes a user or organisation name")
return 2
variables.setdefault("login", args.login)
why_not = refusal(document)
if why_not:
log.error("refusing to send: %s", why_not)
return 2
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (a read-only token is enough)")
return 2
baseline = None
if args.baseline:
recorded = json.loads(Path(args.baseline).read_text(encoding="utf-8"))
value = recorded.get(args.name) if isinstance(recorded, dict) else None
baseline = value if isinstance(value, int) else None
log.info("point cost: %d point(s) against the 5,000/hour GraphQL budget",
point_cost(1))
predicted, unresolved = predicted_cost(document, variables)
slices = slice_values(document, variables)
log.info("predicted from the text: %d point(s) from %d slicing argument(s) "
"totalling %d", predicted, len(slices),
sum(v["value"] for v in slices if isinstance(v["value"], int)))
if unresolved:
log.info("%d slicing argument(s) could not be resolved, so the "
"prediction is a lower bound", unresolved)
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
# GitHub rejects requests with no User-Agent outright.
"User-Agent": UA,
})
status, body = run_query(session, inject_rate_limit(document), variables)
if isinstance(body, dict) and body.get("errors"):
log.error("the query itself failed: %s", json.dumps(body["errors"])[:400])
return 2
measured = measured_cost(body)
nodes = measured_nodes(body)
returned = returned_nodes(body.get("data") if isinstance(body, dict) else None)
log.info("measured by the server: %s point(s), nodeCount %s",
"?" if measured is None else measured, "?" if nodes is None else nodes)
if args.assumed is not None:
log.info("assumed by the caller: %d point(s)", args.assumed)
if baseline is not None:
log.info("recorded baseline: %d point(s)", baseline)
state, detail = classify(measured, predicted, baseline, returned)
log.info("%s: %s", state, detail)
if measured is not None:
log.info("%d node(s) came back for %d point(s), so the price is not the "
"size of the answer", returned, measured)
if args.assumed is not None and args.assumed != measured:
log.info("the assumption is out by a factor of %.1f, and every "
"capacity number built on it is out by the same factor",
measured / float(max(1, args.assumed)))
projected = points_per_hour(measured, args.calls_per_hour)
if projected:
log.info("at %d call(s)/hour this shape needs %d points/hour. What that "
"means against your quota is /github/graphql-rate-limited/",
args.calls_per_hour, projected)
log.info("repair: %s", repair(state))
if measured is not None:
log.info('record it: "%s": %d', args.name, measured)
print(json.dumps({"points_spent": point_cost(1), "state": state,
"predicted": predicted, "measured": measured,
"assumed": args.assumed, "baseline": baseline,
"node_count": nodes, "returned_nodes": returned,
"points_per_hour": projected, "slices": slices},
indent=2, default=str))
return 1 if state in ("cost-increased-since-the-baseline",
"cost-above-the-shape-of-the-query",
"cost-unrelated-to-the-data-returned") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Measure what a GraphQL query costs and compare it with what anybody assumed.
*
* Read only, and queries only. GitHub's GraphQL endpoint takes a document in
* the request body, so a read is carried by POST there just as a write would
* be; that is transport, not intent. Any document containing a mutation or a
* subscription is refused before a socket opens. The baseline file is printed
* for you to update rather than rewritten here.
*
* Environment:
* GITHUB_TOKEN a token with read access to the GraphQL API
* GITHUB_QUERY the document as a string
* GITHUB_VARIABLES JSON object of variables
* GITHUB_LOGIN user or organisation for the default query
* GITHUB_ASSUMED the cost somebody believes this has
* GITHUB_CALLS how often this shape is sent, for an hourly projection
*/
const API = 'https://api.github.com';
const UA = 'github-graphql-cost/1.0';
export const POINTS_PER_QUERY = 1;
/** The selection that makes a response report its own price. */
export const RATE_LIMIT_SELECTION = 'rateLimit { cost nodeCount limit remaining resetAt }';
const DEFAULT_QUERY = 'query($login: String!) {'
+ ' repositoryOwner(login: $login) {'
+ ' repositories(first: 50) { totalCount nodes { name'
+ ' issues(first: 20, states: OPEN) { totalCount nodes { number } } } }'
+ ' } }';
/** Comments and string literals replaced by spaces. Length preserving. Pure. */
export function blankNoise(document) {
const src = String(document ?? '');
const out = src.split('');
let i = 0;
while (i < src.length) {
const ch = src[i];
if (ch === '#') {
while (i < src.length && src[i] !== '\n') { out[i] = ' '; i += 1; }
continue;
}
if (src.startsWith('"""', i)) {
const j = src.indexOf('"""', i + 3);
const end = j < 0 ? src.length : j + 3;
for (let k = i; k < end; k += 1) out[k] = ' ';
i = end;
continue;
}
if (ch === '"') {
out[i] = ' ';
i += 1;
while (i < src.length && src[i] !== '"') {
const step = src[i] === '\\' ? 2 : 1;
for (let k = i; k < Math.min(src.length, i + step); k += 1) out[k] = ' ';
i += step;
}
if (i < src.length) out[i] = ' ';
i += 1;
continue;
}
i += 1;
}
return out.join('');
}
/** The top-level operations in a document, in order. Pure. */
export function operations(document) {
const src = `${blankNoise(document)} `;
const ops = [];
let depth = 0;
let word = '';
let declared = null;
for (const ch of src) {
if (/[A-Za-z0-9_]/.test(ch)) { word += ch; continue; }
if (word) {
if (depth === 0 && ['query', 'mutation', 'subscription', 'fragment'].includes(word)) {
declared = word;
}
word = '';
}
if (ch === '{') {
if (depth === 0) { ops.push(declared || 'query'); declared = null; }
depth += 1;
} else if (ch === '}') {
depth = Math.max(0, depth - 1);
}
}
return ops;
}
/** Why this document will not be sent, or null if it is a read. Pure. */
export function refusal(document) {
const ops = operations(document);
if (ops.length === 0) return 'the document contains no operation to send.';
for (const kind of ['mutation', 'subscription']) {
if (ops.includes(kind)) {
return `the document contains a ${kind}. This script sends queries only: `
+ 'a query is a read, and the section it belongs to promises its '
+ 'scripts never write.';
}
}
return null;
}
/** Index of the operation's opening brace, or -1. Pure. */
export function selectionSetStart(document) {
const src = blankNoise(document);
let parens = 0;
for (let i = 0; i < src.length; i += 1) {
const ch = src[i];
if (ch === '(') parens += 1;
else if (ch === ')') parens = Math.max(0, parens - 1);
else if (ch === '{' && parens === 0) return i;
}
return -1;
}
/** The document with rateLimit added to its top-level selection. Pure. */
export function injectRateLimit(document) {
const src = String(document ?? '');
if (blankNoise(src).includes('rateLimit')) return src;
const at = selectionSetStart(src);
if (at < 0) return src;
return `${src.slice(0, at + 1)} ${RATE_LIMIT_SELECTION}${src.slice(at + 1)}`;
}
/** The first and last arguments in one argument list. Pure. */
export function slicingPairs(argumentText) {
const src = String(argumentText ?? '');
const parts = [];
let depth = 0;
let cur = '';
for (const ch of src) {
if ('([{'.includes(ch)) depth += 1;
else if (')]}'.includes(ch)) depth = Math.max(0, depth - 1);
if (ch === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
cur += ch;
}
parts.push(cur);
const out = [];
for (const part of parts) {
const at = part.indexOf(':');
if (at < 0) continue;
const key = part.slice(0, at).trim();
if (key === 'first' || key === 'last') out.push([key, part.slice(at + 1).trim()]);
}
return out;
}
/** Defaults declared in the operation's variable definitions. Pure. */
export function variableDefaults(document) {
const head = blankNoise(document).split('{')[0];
const out = {};
for (const part of head.replace(/\(/g, ' ').replace(/\)/g, ' ').split(',')) {
const at = part.indexOf(':');
if (at < 0) continue;
const name = part.slice(0, at).trim().split(/\s+/).pop();
const rest = part.slice(at + 1);
if (!name || !name.startsWith('$') || !rest.includes('=')) continue;
out[name] = rest.slice(rest.indexOf('=') + 1).trim();
}
return out;
}
/** An integer, or null if this is not one. Pure. */
export function asInt(value) {
const text = String(value ?? '').trim();
if (!/^-?[0-9]+$/.test(text)) return null;
return Number(text);
}
/** One written slicing value resolved to [value, source]. Pure. */
export function resolveSlice(raw, defaults, variables) {
const text = String(raw ?? '').trim();
if (!text) return [null, 'missing'];
if (!text.startsWith('$')) return [asInt(text), 'literal'];
const supplied = (variables && typeof variables === 'object') ? variables : {};
const bare = text.slice(1);
if (Object.prototype.hasOwnProperty.call(supplied, bare)) {
return [asInt(supplied[bare]), 'variable-supplied'];
}
if (defaults && Object.prototype.hasOwnProperty.call(defaults, text)) {
return [asInt(defaults[text]), 'variable-default'];
}
return [null, 'unresolved'];
}
/** Every first and last in the document, resolved. Pure. */
export function sliceValues(document, variables) {
const src = blankNoise(document);
const defaults = variableDefaults(document);
const out = [];
let i = 0;
let word = '';
let field = '';
while (i < src.length) {
const ch = src[i];
if (/[A-Za-z0-9_]/.test(ch)) { word += ch; i += 1; continue; }
if (word) { field = word; word = ''; }
if (ch === '(') {
let j = i;
let level = 0;
while (j < src.length) {
if (src[j] === '(') level += 1;
else if (src[j] === ')') { level -= 1; if (level === 0) break; }
j += 1;
}
for (const [arg, raw] of slicingPairs(src.slice(i + 1, j))) {
const [value, source] = resolveSlice(raw, defaults, variables);
out.push({ field, arg, written: raw, value, source });
}
i = j + 1;
continue;
}
i += 1;
}
return out;
}
/** The documented approximation, from the text. Pure. Returns [points, unresolved]. */
export function predictedCost(document, variables) {
const values = sliceValues(document, variables);
let total = 0;
let unresolved = 0;
for (const v of values) {
if (Number.isInteger(v.value) && v.value > 0) total += v.value;
else unresolved += 1;
}
return [Math.max(1, Math.ceil(total / 100)), unresolved];
}
/** The rateLimit object anywhere in a response. Pure. */
export function findRateLimit(body) {
if (Array.isArray(body)) {
for (const item of body) {
const found = findRateLimit(item);
if (found !== null) return found;
}
return null;
}
if (!body || typeof body !== 'object') return null;
if (body.rateLimit && typeof body.rateLimit === 'object') return body.rateLimit;
for (const value of Object.values(body)) {
const found = findRateLimit(value);
if (found !== null) return found;
}
return null;
}
/** What the server charged for this call, or null. Pure. */
export function measuredCost(body) {
const node = findRateLimit(body) || {};
return Number.isInteger(node.cost) ? node.cost : null;
}
/** The node count the server computed for this call, or null. Pure. */
export function measuredNodes(body) {
const node = findRateLimit(body) || {};
return Number.isInteger(node.nodeCount) ? node.nodeCount : null;
}
/** How many items actually came back in every nodes list. Pure. */
export function returnedNodes(body) {
let total = 0;
if (Array.isArray(body)) {
for (const item of body) total += returnedNodes(item);
return total;
}
if (!body || typeof body !== 'object') return 0;
for (const [key, value] of Object.entries(body)) {
if ((key === 'nodes' || key === 'edges') && Array.isArray(value)) total += value.length;
total += returnedNodes(value);
}
return total;
}
/** The disagreement between the text and the server. Pure. */
export function gap(predicted, measured) {
if (measured === null || measured === undefined) return [null, 'unmeasured'];
if (!predicted || predicted <= 0) return [null, 'unpredictable'];
const ratio = measured / predicted;
if (ratio >= 2) return [ratio, 'far-above-the-text'];
if (ratio > 1.25) return [ratio, 'above-the-text'];
if (ratio < 0.75) return [ratio, 'below-the-text'];
return [ratio, 'close-to-the-text'];
}
/** This shape's price against the recorded one. Pure. */
export function drift(baseline, measured) {
if (!Number.isInteger(baseline)) {
return ['no-baseline', 'no recorded cost for this shape, so nothing can be '
+ 'compared. Record this one and the next change becomes visible.'];
}
if (measured === null || measured === undefined) {
return ['unmeasured', 'nothing to compare the baseline against.'];
}
if (measured === baseline) {
return ['unchanged', `this shape costs the same ${baseline} point(s) it did `
+ 'when the baseline was written.'];
}
const direction = measured > baseline ? 'rise' : 'fall';
const percent = (Math.abs(measured - baseline) * 100) / Math.max(1, baseline);
return [measured > baseline ? 'increased' : 'decreased',
`this shape cost ${baseline} point(s) when the baseline was written and `
+ `costs ${measured} now, a ${direction} of ${percent.toFixed(0)}%.`];
}
/** Classify one measurement. Pure. Returns [state, detail]. */
export function classify(measured, predicted, baseline, returned) {
if (measured === null || measured === undefined) {
return ['cost-unmeasured', 'the response carried no rateLimit { cost }, so '
+ 'this run measured nothing. Nothing else here is worth reading.'];
}
const [driftState, driftDetail] = drift(baseline, measured);
if (driftState === 'increased') return ['cost-increased-since-the-baseline', driftDetail];
const [ratio, verdict] = gap(predicted, measured);
if (verdict === 'far-above-the-text' || verdict === 'above-the-text') {
return ['cost-above-the-shape-of-the-query',
`the server charged ${measured} where the document predicted ${predicted}, `
+ `a factor of ${ratio.toFixed(1)}.`];
}
if (Number.isInteger(returned) && measured >= 5 && returned <= measured) {
return ['cost-unrelated-to-the-data-returned',
`${returned} node(s) came back for ${measured} point(s). The price follows `
+ 'what the query asked for, not what it found.'];
}
return ['cost-measured', `this shape costs ${measured} point(s), which is what `
+ 'the document predicts and what the baseline says.'];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state) {
if (state === 'cost-increased-since-the-baseline') {
return 'record the new cost against the shape and treat the change as part '
+ 'of the diff that caused it. A price change belongs in a code review, '
+ 'not in an incident.';
}
if (state === 'cost-above-the-shape-of-the-query') {
return 'find what the document traverses that the arithmetic did not see -- '
+ 'usually a connection nested inside another -- and split the query '
+ 'rather than widening the budget.';
}
if (state === 'cost-unrelated-to-the-data-returned') {
return 'lower the first values rather than filtering harder. Filters change '
+ 'what comes back; only the slice changes the price.';
}
if (state === 'cost-unmeasured') {
return 'add rateLimit { cost nodeCount remaining } to the query. It costs no '
+ 'extra round trip and there is no other way to learn the number.';
}
if (state === 'cost-measured') {
return 'record this number so the next change to the query has something to '
+ 'be compared against.';
}
return 'point the check at a document this endpoint can answer.';
}
/** What a schedule spends. Pure. */
export function pointsPerHour(cost, callsPerHour) {
if (!Number.isInteger(cost) || !callsPerHour) return null;
return cost * Number(callsPerHour);
}
/** Points this run will spend. Pure. */
export function pointCost(queries) {
return Number(queries || 0) * POINTS_PER_QUERY;
}
function headers(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
'User-Agent': UA,
};
}
async function runQuery(token, document, variables) {
const res = await fetch(`${API}/graphql`, {
// A GraphQL query is a read. POST is only how the document reaches the
// endpoint, and refusal() has already rejected anything that is not a read.
method: 'POST',
headers: headers(token),
body: JSON.stringify({ query: document, variables: variables || {} }),
});
let body = null;
try { body = await res.json(); } catch { body = null; }
return { status: res.status, body };
}
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 document = process.env.GITHUB_QUERY || DEFAULT_QUERY;
let variables = {};
try { variables = JSON.parse(process.env.GITHUB_VARIABLES || '{}'); } catch {
console.error('GITHUB_VARIABLES takes a JSON object');
process.exitCode = 2;
return;
}
if (!process.env.GITHUB_QUERY) {
if (!process.env.GITHUB_LOGIN) {
console.error('set GITHUB_LOGIN to a user or organisation name');
process.exitCode = 2;
return;
}
variables.login = process.env.GITHUB_LOGIN;
}
const whyNot = refusal(document);
if (whyNot) {
console.error(`refusing to send: ${whyNot}`);
process.exitCode = 2;
return;
}
console.log(`point cost: ${pointCost(1)} point(s) against the 5,000/hour GraphQL budget`);
const [predicted, unresolved] = predictedCost(document, variables);
const slices = sliceValues(document, variables);
const asked = slices.reduce((a, v) => a + (Number.isInteger(v.value) ? v.value : 0), 0);
console.log(`predicted from the text: ${predicted} point(s) from ${slices.length} `
+ `slicing argument(s) totalling ${asked}`);
if (unresolved) {
console.log(`${unresolved} slicing argument(s) could not be resolved, so the `
+ 'prediction is a lower bound');
}
const { body } = await runQuery(token, injectRateLimit(document), variables);
if (body && Array.isArray(body.errors) && body.errors.length) {
console.error(`the query itself failed: ${JSON.stringify(body.errors).slice(0, 400)}`);
process.exitCode = 2;
return;
}
const measured = measuredCost(body);
const nodes = measuredNodes(body);
const returned = returnedNodes(body && body.data);
const assumed = process.env.GITHUB_ASSUMED ? Number(process.env.GITHUB_ASSUMED) : null;
console.log(`measured by the server: ${measured === null ? '?' : measured} point(s), `
+ `nodeCount ${nodes === null ? '?' : nodes}`);
if (assumed !== null) console.log(`assumed by the caller: ${assumed} point(s)`);
const [state, detail] = classify(measured, predicted, null, returned);
console.log(`${state}: ${detail}`);
if (measured !== null) {
console.log(`${returned} node(s) came back for ${measured} point(s), so the `
+ 'price is not the size of the answer');
}
const projected = pointsPerHour(measured, Number(process.env.GITHUB_CALLS || 0));
if (projected) {
console.log(`at ${process.env.GITHUB_CALLS} call(s)/hour this shape needs `
+ `${projected} points/hour. What that means against your quota is `
+ '/github/graphql-rate-limited/');
}
console.log(`repair: ${repair(state)}`);
console.log(JSON.stringify({
points_spent: pointCost(1), state, predicted, measured, assumed,
node_count: nodes, returned_nodes: returned, points_per_hour: projected, slices,
}, null, 2));
process.exitCode = ['cost-increased-since-the-baseline',
'cost-above-the-shape-of-the-query',
'cost-unrelated-to-the-data-returned'].includes(state) ? 1 : 0;
}
// Guarded so importing this file from the test runner does not start a run.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The surgery gets the most attention, because a broken injection produces a document that does not parse and a run that measures nothing. It is asserted to land inside the operation's selection set, to leave a document that already asks for rateLimit exactly as it found it, and to step over a brace that belongs to an input object in the variable definitions rather than to the query. After that the arithmetic: the prediction, the three-way comparison between predicted, measured and assumed, the drift against a baseline, and the count of nodes actually returned, which is what makes the point about price not following data.
from github_graphql_cost import (
POINTS_PER_QUERY, blank_noise, classify, drift, gap, inject_rate_limit,
measured_cost, measured_nodes, operations, point_cost, points_per_hour,
predicted_cost, refusal, repair, returned_nodes, selection_set_start,
slice_values,
)
QUERY = ("query($login: String!) { repositoryOwner(login: $login) {"
" repositories(first: 50) { nodes { name"
" issues(first: 20) { nodes { number } } } } } }")
BODY = {"data": {
"rateLimit": {"cost": 14, "nodeCount": 3180, "limit": 5000, "remaining": 4986},
"repositoryOwner": {"repositories": {"nodes": [
{"name": "a", "issues": {"nodes": [{"number": 1}, {"number": 2}]}},
{"name": "b", "issues": {"nodes": [{"number": 3}]}},
]}}}}
def test_the_injection_lands_in_the_operations_selection_set():
out = inject_rate_limit(QUERY)
assert "rateLimit { cost nodeCount limit remaining resetAt }" in out
at = out.index("rateLimit")
assert out.index("repositoryOwner") > at
assert out.count("rateLimit") == 1
def test_a_document_that_already_asks_for_it_is_left_alone():
once = inject_rate_limit(QUERY)
assert inject_rate_limit(once) == once
already = "query { rateLimit { cost } viewer { login } }"
assert inject_rate_limit(already) == already
def test_a_brace_in_the_variable_definitions_is_not_the_selection_set():
doc = 'query($order: IssueOrder = {field: CREATED_AT}) { viewer { login } }'
at = selection_set_start(doc)
assert doc[at:at + 3] == "{ v"
out = inject_rate_limit(doc)
assert out.index("IssueOrder") < out.index("rateLimit") < out.index("viewer")
def test_blanking_the_noise_keeps_every_index_where_it_was():
doc = 'query { search(query: "a { b }", type: ISSUE, first: 5) { issueCount } }'
blanked = blank_noise(doc)
assert len(blanked) == len(doc)
assert "{ b }" not in blanked
assert blanked.index("issueCount") == doc.index("issueCount")
def test_the_prediction_comes_from_the_slices_and_never_from_zero():
assert predicted_cost(QUERY, {}) == (1, 0)
big = "query { a(first: 100) { nodes { b(first: 100) { nodes { id } } } } }"
assert predicted_cost(big, {})[0] == 2
assert predicted_cost("query { viewer { login } }", {}) == (1, 0)
def test_an_unresolved_slice_makes_the_prediction_a_lower_bound():
doc = "query($n: Int!) { a(first: $n) { nodes { id } } }"
points, unresolved = predicted_cost(doc, {})
assert unresolved == 1
assert points == 1
assert predicted_cost(doc, {"n": 300})[0] == 3
def test_a_variable_definition_is_not_counted_as_a_slice():
doc = "query($first: Int = 250) { a(first: 10) { nodes { id } } }"
assert [(v["arg"], v["value"]) for v in slice_values(doc, {})] == [("first", 10)]
def test_the_server_number_is_read_out_of_the_response_wherever_it_sits():
assert measured_cost(BODY) == 14
assert measured_nodes(BODY) == 3180
assert measured_cost({"data": {"viewer": {"login": "x"}}}) is None
assert measured_cost(None) is None
def test_the_price_is_compared_with_the_data_that_came_back():
assert returned_nodes(BODY["data"]) == 5
assert returned_nodes({"nodes": [1, 2, 3]}) == 3
assert returned_nodes({"name": "a"}) == 0
def test_the_gap_between_the_text_and_the_server_is_the_finding():
assert gap(3, 14)[1] == "far-above-the-text"
assert gap(4, 6)[1] == "above-the-text"
assert gap(4, 4)[1] == "close-to-the-text"
assert gap(10, 2)[1] == "below-the-text"
assert gap(3, None)[1] == "unmeasured"
def test_drift_against_a_recorded_baseline_is_reported_as_a_percentage():
state, detail = drift(3, 14)
assert state == "increased"
assert "367%" in detail
assert drift(3, 3)[0] == "unchanged"
assert drift(14, 3)[0] == "decreased"
assert drift(None, 14)[0] == "no-baseline"
def test_a_price_rise_outranks_everything_else_because_it_is_reviewable():
state, detail = classify(14, 3, 3, 5)
assert state == "cost-increased-since-the-baseline"
assert "367%" in detail
assert "code review" in repair(state)
def test_a_query_costing_more_than_its_text_suggests_is_named_as_that():
state, detail = classify(14, 3, None, 5)
assert state == "cost-above-the-shape-of-the-query"
assert "factor of 4.7" in detail
def test_cost_not_following_the_data_is_its_own_finding():
state, detail = classify(9, 9, 9, 4)
assert state == "cost-unrelated-to-the-data-returned"
assert "4 node(s) came back for 9 point(s)" in detail
assert "Filters change" in repair(state)
def test_an_unmeasured_run_says_so_rather_than_guessing():
state, _detail = classify(None, 3, 3, 5)
assert state == "cost-unmeasured"
assert "rateLimit { cost nodeCount remaining }" in repair(state)
def test_the_hourly_projection_is_multiplication_and_nothing_more():
assert points_per_hour(14, 240) == 3360
assert points_per_hour(14, 0) is None
assert points_per_hour(None, 240) is None
def test_the_script_refuses_to_send_a_mutation():
assert operations("query Q { viewer { login } }") == ["query"]
assert refusal("mutation M { addStar(input: {}) { clientMutationId } }")
assert refusal("subscription S { thing { id } }")
assert refusal(QUERY) is None
def test_the_run_says_what_it_will_spend():
assert POINTS_PER_QUERY == 1
assert point_cost(1) == 1
assert point_cost(0) == 0
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
POINTS_PER_QUERY, blankNoise, classify, drift, gap, injectRateLimit,
measuredCost, measuredNodes, operations, pointCost, pointsPerHour,
predictedCost, refusal, repair, returnedNodes, selectionSetStart, sliceValues,
} from './github-graphql-cost.mjs';
const QUERY = 'query($login: String!) { repositoryOwner(login: $login) {'
+ ' repositories(first: 50) { nodes { name'
+ ' issues(first: 20) { nodes { number } } } } } }';
const BODY = {
data: {
rateLimit: {
cost: 14, nodeCount: 3180, limit: 5000, remaining: 4986,
},
repositoryOwner: {
repositories: {
nodes: [
{ name: 'a', issues: { nodes: [{ number: 1 }, { number: 2 }] } },
{ name: 'b', issues: { nodes: [{ number: 3 }] } },
],
},
},
},
};
test('the injection lands in the operation selection set', () => {
const out = injectRateLimit(QUERY);
assert.ok(out.includes('rateLimit { cost nodeCount limit remaining resetAt }'));
assert.ok(out.indexOf('repositoryOwner') > out.indexOf('rateLimit'));
assert.equal(out.split('rateLimit').length - 1, 1);
});
test('a document that already asks for it is left alone', () => {
const once = injectRateLimit(QUERY);
assert.equal(injectRateLimit(once), once);
const already = 'query { rateLimit { cost } viewer { login } }';
assert.equal(injectRateLimit(already), already);
});
test('a brace in the variable definitions is not the selection set', () => {
const doc = 'query($order: IssueOrder = {field: CREATED_AT}) { viewer { login } }';
const at = selectionSetStart(doc);
assert.equal(doc.slice(at, at + 3), '{ v');
const out = injectRateLimit(doc);
assert.ok(out.indexOf('IssueOrder') < out.indexOf('rateLimit'));
assert.ok(out.indexOf('rateLimit') < out.indexOf('viewer'));
});
test('blanking the noise keeps every index where it was', () => {
const doc = 'query { search(query: "a { b }", type: ISSUE, first: 5) { issueCount } }';
const blanked = blankNoise(doc);
assert.equal(blanked.length, doc.length);
assert.ok(!blanked.includes('{ b }'));
assert.equal(blanked.indexOf('issueCount'), doc.indexOf('issueCount'));
});
test('the prediction comes from the slices and never from zero', () => {
assert.deepEqual(predictedCost(QUERY, {}), [1, 0]);
const big = 'query { a(first: 100) { nodes { b(first: 100) { nodes { id } } } } }';
assert.equal(predictedCost(big, {})[0], 2);
assert.deepEqual(predictedCost('query { viewer { login } }', {}), [1, 0]);
});
test('an unresolved slice makes the prediction a lower bound', () => {
const doc = 'query($n: Int!) { a(first: $n) { nodes { id } } }';
const [points, unresolved] = predictedCost(doc, {});
assert.equal(unresolved, 1);
assert.equal(points, 1);
assert.equal(predictedCost(doc, { n: 300 })[0], 3);
});
test('a variable definition is not counted as a slice', () => {
const doc = 'query($first: Int = 250) { a(first: 10) { nodes { id } } }';
assert.deepEqual(sliceValues(doc, {}).map((v) => [v.arg, v.value]), [['first', 10]]);
});
test('the server number is read out of the response wherever it sits', () => {
assert.equal(measuredCost(BODY), 14);
assert.equal(measuredNodes(BODY), 3180);
assert.equal(measuredCost({ data: { viewer: { login: 'x' } } }), null);
assert.equal(measuredCost(null), null);
});
test('the price is compared with the data that came back', () => {
assert.equal(returnedNodes(BODY.data), 5);
assert.equal(returnedNodes({ nodes: [1, 2, 3] }), 3);
assert.equal(returnedNodes({ name: 'a' }), 0);
});
test('the gap between the text and the server is the finding', () => {
assert.equal(gap(3, 14)[1], 'far-above-the-text');
assert.equal(gap(4, 6)[1], 'above-the-text');
assert.equal(gap(4, 4)[1], 'close-to-the-text');
assert.equal(gap(10, 2)[1], 'below-the-text');
assert.equal(gap(3, null)[1], 'unmeasured');
});
test('drift against a recorded baseline is reported as a percentage', () => {
const [state, detail] = drift(3, 14);
assert.equal(state, 'increased');
assert.match(detail, /367%/);
assert.equal(drift(3, 3)[0], 'unchanged');
assert.equal(drift(14, 3)[0], 'decreased');
assert.equal(drift(null, 14)[0], 'no-baseline');
});
test('a price rise outranks everything else because it is reviewable', () => {
const [state, detail] = classify(14, 3, 3, 5);
assert.equal(state, 'cost-increased-since-the-baseline');
assert.match(detail, /367%/);
assert.match(repair(state), /code review/);
});
test('a query costing more than its text suggests is named as that', () => {
const [state, detail] = classify(14, 3, null, 5);
assert.equal(state, 'cost-above-the-shape-of-the-query');
assert.match(detail, /factor of 4\.7/);
});
test('cost not following the data is its own finding', () => {
const [state, detail] = classify(9, 9, 9, 4);
assert.equal(state, 'cost-unrelated-to-the-data-returned');
assert.match(detail, /4 node\(s\) came back for 9 point\(s\)/);
assert.match(repair(state), /Filters change/);
});
test('an unmeasured run says so rather than guessing', () => {
const [state] = classify(null, 3, 3, 5);
assert.equal(state, 'cost-unmeasured');
assert.match(repair(state), /rateLimit \{ cost nodeCount remaining \}/);
});
test('the hourly projection is multiplication and nothing more', () => {
assert.equal(pointsPerHour(14, 240), 3360);
assert.equal(pointsPerHour(14, 0), null);
assert.equal(pointsPerHour(null, 240), null);
});
test('the script refuses to send a mutation', () => {
assert.deepEqual(operations('query Q { viewer { login } }'), ['query']);
assert.ok(refusal('mutation M { addStar(input: {}) { clientMutationId } }'));
assert.ok(refusal('subscription S { thing { id } }'));
assert.equal(refusal(QUERY), null);
});
test('the run says what it will spend', () => {
assert.equal(POINTS_PER_QUERY, 1);
assert.equal(pointCost(1), 1);
assert.equal(pointCost(0), 0);
});
FAQ
How is the cost actually calculated?
From the request rather than the response. GitHub derives it from the number of unique connections the query could traverse and the slice each one was given, then divides by 100 and rounds up, with a minimum of one point per query. The important consequence is that filtering does not make a query cheaper: a connection asked for a hundred items costs the same whether it finds a hundred or none. Only lowering the slice, or removing a connection, moves the number.
Does adding rateLimit to my query make it more expensive?
No. It is a field on the query root and it does not traverse a connection, so it adds nothing to the cost of a query you were sending anyway. Sending it as a query all by itself costs one point, like any other query. That is the argument for putting it into your real queries permanently rather than probing for it: the measurement becomes continuous and free, and you end up with a log of what each shape costs over time instead of a single reading.
What is the difference between cost and nodeCount?
Cost is what you are billed, in points against the hourly budget. nodeCount is how many nodes the query could return, which is checked against a separate ceiling of 500,000 and has nothing to do with the budget. A query can be cheap and over the node limit, or expensive and nowhere near it. They are reported side by side because they are both computed from the request, but they answer different questions and have different failure modes.
My query costs one point. Do I need any of this?
Not today, and that is exactly how the trap is set. The queries that break a budget were all one point once, and they grew a field at a time with nobody watching the price because there was never a moment where watching it seemed necessary. Recording the number costs nothing and turns the eventual change into a line in a diff. If you only do one thing, put rateLimit into the query and log the cost; the comparison can come later.
Should the baseline live in the repository or in monitoring?
In the repository, next to the queries. The change you want to catch arrives in a pull request weeks before it causes an incident, and a file a reviewer can see the diff of catches it there. Monitoring catches it once it is already spending real quota, which is later and more expensive. This script deliberately does not update the file for you: it prints the line to add, so changing a recorded price is always somebody's explicit decision.
Related field notes
- The budget these points are spent from
- A query charged extra for taking too long
- The node ceiling, which is not the budget
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 and node limits for the GraphQL API — GitHub Docs
- Resource limitations — GitHub GraphQL API
- Using the GraphQL API — GitHub Docs
- Rate limit — GitHub REST API
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.