Diagnostic GitHub API
A GraphQL connection asks for first: 500 and is rejected
The query is fifteen lines long and it has never run once. The body comes back with Argument 'first' on Field 'issues' has an invalid value (500), there is no data key in it at all, and the 500 is not written anywhere in the document — it is the default on a variable somebody added a year ago. The code was ported from a REST client where per_page=500 quietly became 100 and the job kept working, so nobody ever learned that the number was wrong.
Every connection in GitHub's GraphQL schema caps first and last at 100. Ask for more and the query is rejected during validation, before a single field is resolved. That is a different response shape from the failures batch R covers: there is no data key in the body at all, the entries in errors have no type field, and nothing is billed against your point budget, because nothing ran.
Set first: 100 and paginate with after: $cursor until pageInfo.hasNextPage is false. The ceiling is not adjustable and there is no header, parameter or preview that raises it. The part worth checking with a script is not the number but where the number comes from: a value over 100 arriving through a variable default or through the variables map is invisible to a text search for first: 500, and that is the version of this bug that survives review.
The problem in plain words
This one is imported rather than invented. The REST API accepts per_page=500, silently gives you 100, and carries on, so a client written against REST has usually learned that oversized page sizes are harmless and that asking for more is a free optimisation. That habit ports across to GraphQL along with the code, and GraphQL does not share it. It refuses.
The refusal is at least loud, which is why this note is short on symptoms and long on where the number hides. In the simple case somebody wrote first: 500, the error names the field and the argument, and the fix takes ninety seconds. The case that eats an afternoon is the one where the document says first: $first and looks entirely reasonable, and the offending value is either a default in the operation's variable definitions or a number computed by the caller and passed in the variables map. Grepping the repository for first: 5 finds nothing.
The computed version is the worst of the three, because it is intermittent by construction. A page size derived from a batch size, a config value or a leftover count works for months while the input is small and fails the first time somebody asks for a big report. It then fails identically on retry, which sends people looking for a rate limit or an outage, because a failure that reproduces exactly usually is one of those.
Why it happens
100 is a schema constant, not a policy. The ceiling on first and last is part of the connection contract in GitHub's schema and applies to every connection in it. There is no scope, header, preview or Enterprise plan that moves it. Anything you read that suggests raising it is describing a different API.
Rejection happens at validation, and validation is not execution. The GraphQL specification says that when a request fails before execution begins, the response must not contain a data entry at all. So the body you get here has errors and nothing else — not data: null, which is what an execution failure produces, but no data key. A client written to read body.data.repository throws on the missing key rather than on a null, which is a different stack trace pointing at a different line.
It costs nothing, and that is a real consolation. The query never ran, so no points are deducted for it. That makes this the cheapest possible failure and it also means a retry loop stuck on it is not draining the budget, unlike almost every other failure in this section. It is still an infinite loop, but it is a free one.
The value is not always in the document. A slicing argument can be written as a literal, as a variable with a default in the operation definition, or as a variable supplied at call time. Only the first is visible to a text search, and the third is not knowable from the repository at all without the caller. Any check worth running has to resolve all three, which is what this script does: it reads the literals, reads the defaults out of the variable definitions, and takes the variables map you actually send.
This is not the node limit. A query can sit at first: 100 on every connection and still be rejected, because the node count multiplies down through the nesting toward a cap of 500,000. That is an aggregate about the whole document; this is a ceiling on one argument, checked per connection, with a different error and a different repair. Lowering a first from 500 to 100 fixes this one and can leave the other one failing.
REST clamps, GraphQL rejects, and the difference is worth keeping. Sending per_page=200 to a REST endpoint gets you 100 items and no complaint, which is its own quiet trap: the client believes it asked for 200 and got everything. GraphQL refusing outright is the better behaviour of the two. It is only surprising because the REST habit came first.
The fix, as a flow
The audit is pure text and the ceiling is a single number, so the interesting work is resolving what the number is being compared against. Every first and last in the document is collected with the field carrying it, then resolved through three sources in the order the server sees them: a literal, a value in the variables map, and a default in the operation's variable definitions. Only then is anything measured against 100. The optional probe sends the document once to show what a validation failure looks like, which is a body with an errors array and no data key at all, and it refuses to open a socket if the document turns out to contain a mutation.
How to fix it
Resolve every slicing argument, not just the literal ones
Feed the document to the checker with the variables you actually send. It lists every first and last in the query, resolves each one through a literal, a variable default or the supplied variables map, and says which of the three it came from. The source is as much of the finding as the number, because it tells you which file to open.
Compare each resolved value against 100
One line per argument, one verdict per line. Over the ceiling is a rejection, exactly 100 is the maximum and fine, and an argument the checker could not resolve is reported as unresolved rather than assumed safe. Nothing is silently rolled into a total, because the ceiling applies to each connection on its own.
Read the pages that number actually implies
The script prints how many round trips the requested size becomes at 100 per page: 500 is five pages, 2,000 is twenty. That is the honest cost of the thing the query was trying to avoid, and seeing it usually settles the argument about whether to paginate or to try to get more per call.
Confirm the shape of the rejection if you want to see it
Sending the document returns a body with errors and no data key at all, which is what validation failure looks like and is worth seeing once. The script names the field and argument out of the message. Use --offline to skip this entirely: the audit needs no token and nothing is sent.
Rewrite as 100 plus a cursor, and check the node count separately
Set the value to 100, add pageInfo { hasNextPage endCursor } and follow after: $cursor until it stops. Then run the node-count check as well, because a document that is legal on every individual argument can still be rejected for the product of them, and that is a different note with a different number.
How to check it worked
The audit runs on a query file with no token and no points, and names both the value and where it came from.
python3 github_graphql_slice.py --file queries/issue_export.graphql --offline
# point cost: 0 point(s). The ceiling is checked against the query text and the
# variables you supply; nothing is sent.
# issues.first written $first value 250 variable-default OVER needs 3 pages
# comments.first written 100 value 100 literal at the ceiling
# labels.first written $labels value ? unresolved supply it to check
# over-ceiling-through-a-variable: issues.first resolves to 250 through a
# variable default, so a search of the document for a number over 100 finds
# nothing and every call is still rejected
# repair: set the default on $first to 100 and page with after: $cursor
GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_graphql_slice.py --repo acme/monorepo
# point cost: up to 1 point(s). A document rejected during validation never
# executes and is not billed at all.
# HTTP 200, phase=validation, data key present=no, errors=1
# rejected argument: first on field issues
# validation-rejected: the body carries errors and no data key, which is what a
# failure before execution looks like
The full code
The audit is pure and needs nothing but text: strip the comments and string literals, walk the document, collect every first and last with the field it belongs to, then resolve each written value through three sources in order — a literal, the operation's variable definitions, and the variables map you pass in. Only after that is anything compared against 100. The optional probe sends the document once, purely to show that a validation failure comes back with no data key, and it refuses to open a socket if the document contains a mutation or a subscription.
"""Resolve every first and last in a GraphQL document against the ceiling of 100.
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 and the repair is printed rather than performed.
Every connection in the schema caps first and last at 100. Over that the query
is rejected during validation, before execution begins, which is why the body
comes back with an errors array and no data key at all and why nothing is
billed. The number being rejected is often not written in the document: it
arrives through a variable default or through the variables map, so a text
search for a literal over 100 reports a clean document that fails every call.
What this can and cannot see: the ceiling is a fact about the schema and the
audit is a fact about the text plus the variables you hand it. A value computed
by a caller at run time is not in either, so the script reports an unresolved
argument as unresolved rather than as safe.
Environment:
GITHUB_TOKEN a token with read access to the GraphQL API. Not needed
with --offline.
"""
import argparse
import json
import logging
import os
import re
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_slice")
API = "https://api.github.com"
UA = "github-graphql-slice/1.0"
# The ceiling on first and last, on every connection in the schema. Not a
# policy, not adjustable, and the same on every plan.
CEILING = 100
# A simple query costs one point, and a query rejected during validation costs
# none at all because it never runs.
POINTS_PER_QUERY = 1
# The default deliberately hides its oversized value in a variable default,
# because that is the version of this bug a grep does not find.
DEFAULT_QUERY = (
"query($owner: String!, $name: String!, $first: Int = 250) {"
" repository(owner: $owner, name: $name) {"
" issues(first: $first, states: OPEN) { totalCount nodes { number title } }"
" } }"
)
ARGUMENT_IN_MESSAGE = re.compile(
"Argument '([A-Za-z_][A-Za-z0-9_]*)' on Field '([A-Za-z_][A-Za-z0-9_]*)'")
def strip_noise(document):
"""Remove GraphQL comments and string literals from a document. Pure.
Written as a scanner rather than a regex because a hash inside a string
literal is a legitimate character and a comment marker outside one.
"""
src = str(document or "")
out = []
i, n = 0, len(src)
while i < n:
ch = src[i]
if ch == "#":
while i < n and src[i] != "\n":
i += 1
continue
if src.startswith('"""', i):
j = src.find('"""', i + 3)
i = n if j < 0 else j + 3
out.append(" ")
continue
if ch == '"':
i += 1
while i < n and src[i] != '"':
i += 2 if src[i] == "\\" else 1
i += 1
out.append(" ")
continue
out.append(ch)
i += 1
return "".join(out)
def operations(document):
"""The top-level operations in a document, in order. Pure."""
src = strip_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.
The endpoint is the same one mutations go to, so the guard lives here rather
than in a comment.
"""
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 argument_value(argument_text, name):
"""The text written for one named argument, or None. Pure.
Splits on top-level commas so a nested object or list argument does not
confuse the scan, and requires an exact name match so a variable definition
such as `$first: Int = 250` is never mistaken for 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)
for part in parts:
key, sep, value = part.partition(":")
if not sep:
continue
if key.strip() == name:
return value.strip() or None
return None
def variable_defaults(document):
"""Defaults declared in the operation's variable definitions. Pure.
Keyed with the leading dollar so the keys read the way they are written in
the document: {"$first": "250"}.
"""
src = strip_noise(document)
head = src.split("{", 1)[0]
out = {}
for part in head.replace("(", " ").replace(")", " ").split(","):
name, sep, rest = part.partition(":")
if not sep:
continue
# The first part still carries the operation keyword and name in front
# of the variable, so take the last token rather than the whole thing.
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 slicing_arguments(document):
"""Every first and last in the document, with the field carrying it. Pure.
Depth is counted in selection sets, so a reader can see which connection is
where without reading the query again.
"""
src = strip_noise(document)
out, i, n, depth, word = [], 0, len(src), 0, ""
while i < n:
ch = src[i]
if ch.isalnum() or ch == "_":
word += ch
i += 1
continue
if ch == "(" and word:
field, j, level = word, i, 0
while j < n:
if src[j] == "(":
level += 1
elif src[j] == ")":
level -= 1
if level == 0:
break
j += 1
args = src[i + 1:j]
for arg in ("first", "last"):
raw = argument_value(args, arg)
if raw is not None:
out.append({"field": field, "arg": arg, "raw": raw, "depth": depth})
i, word = j + 1, ""
continue
if ch == "{":
depth += 1
elif ch == "}":
depth = max(0, depth - 1)
word = ""
i += 1
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.
Three sources in the order that decides the call: a literal in the document,
a value supplied in the variables map, and a default in the operation's
variable definitions. A supplied value beats a default because that is what
the server sees.
"""
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 verdict(value):
"""One resolved value against the ceiling. Pure."""
if value is None:
return "unresolved"
if value < 1:
return "below-one"
if value > CEILING:
return "over-ceiling"
if value == CEILING:
return "at-ceiling"
return "under-ceiling"
def pages_needed(value):
"""Round trips at 100 per page for a requested size. Pure."""
if value is None or value < 1:
return None
return -(-value // CEILING)
def audit(document, variables=None):
"""Every slicing argument, resolved and judged. Pure."""
defaults = variable_defaults(document)
out = []
for found in slicing_arguments(document):
value, source = resolve_slice(found["raw"], defaults, variables)
out.append({
"field": found["field"],
"arg": found["arg"],
"depth": found["depth"],
"written": found["raw"],
"value": value,
"source": source,
"verdict": verdict(value),
"pages": pages_needed(value),
})
return out
def classify(findings):
"""Classify a whole document. Pure. Returns (state, detail)."""
if not findings:
return ("no-slicing-argument",
"no first or last appears anywhere in this document. GitHub "
"requires a slicing argument on every connection, so either "
"there is no connection here or the query is rejected for a "
"different reason than this note describes.")
over = [f for f in findings if f["verdict"] == "over-ceiling"]
literal = [f for f in over if f["source"] == "literal"]
if literal:
f = literal[0]
return ("over-ceiling-in-the-document",
"%s.%s asks for %d, which is over the ceiling of %d, and the "
"number is written in the query."
% (f["field"], f["arg"], f["value"], CEILING))
if over:
f = over[0]
return ("over-ceiling-through-a-variable",
"%s.%s resolves to %d through a %s, so a search of the document "
"for a number over %d finds nothing and every call is still "
"rejected." % (f["field"], f["arg"], f["value"], f["source"], CEILING))
unresolved = [f for f in findings if f["verdict"] == "unresolved"]
if unresolved:
f = unresolved[0]
return ("unresolved-slice",
"%s.%s is written as %s and no default or supplied value "
"explains it, so this document cannot be cleared from the text "
"alone." % (f["field"], f["arg"], f["written"]))
below = [f for f in findings if f["verdict"] == "below-one"]
if below:
f = below[0]
return ("slice-below-one",
"%s.%s resolves to %d. The range is 1 to %d and zero is "
"rejected the same way an oversized value is."
% (f["field"], f["arg"], f["value"], CEILING))
return ("within-the-ceiling",
"all %d slicing argument(s) resolve to between 1 and %d. This "
"document is not rejected for an argument value; the node count is "
"a separate question." % (len(findings), CEILING))
def repair(state):
"""The sentence a reader has to act on. Pure."""
if state == "over-ceiling-in-the-document":
return ("set the value to 100 and page with after: $cursor until "
"pageInfo.hasNextPage is false. The ceiling is not adjustable.")
if state == "over-ceiling-through-a-variable":
return ("fix the value where it is set, not in the query text. Cap it "
"at 100 in the caller or in the variable default, and page "
"with after: $cursor for the rest.")
if state == "unresolved-slice":
return ("run this again with --variables so the value can be resolved. "
"An argument nobody can resolve is not an argument anybody has "
"checked.")
if state == "slice-below-one":
return ("use a value of at least 1. A slicing argument of 0 is not a "
"cheap query, it is a rejected one.")
if state == "within-the-ceiling":
return ("nothing on the argument ceiling. Check the node count as well: "
"see /github/graphql-node-limit-exceeded/ -- a document legal on "
"every argument can still be rejected for the product of them.")
return "point the check at a document containing a connection."
def error_phase(status, body):
"""Which phase of the request failed. Pure.
The specification is precise here and the distinction is the whole reason
this note has a different shape from the errors-array notes: a request that
fails before execution must not carry a data entry at all, while one that
fails during execution carries data with nulls in it.
"""
if not isinstance(body, dict):
return "unreadable"
if not body.get("errors"):
return "clean"
if "data" not in body:
return "validation"
return "execution"
def offending_argument(body):
"""The (argument, field) the server named, or (None, None). Pure."""
if not isinstance(body, dict):
return (None, None)
for err in body.get("errors") or []:
message = err.get("message", "") if isinstance(err, dict) else ""
m = ARGUMENT_IN_MESSAGE.search(message)
if m:
return (m.group(1), m.group(2))
return (None, None)
def point_cost(sending):
"""Points this run can spend. Pure."""
return POINTS_PER_QUERY if sending else 0
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 audit")
ap.add_argument("--query", help="the document as a string")
ap.add_argument("--variables", default="{}",
help="JSON object of the variables you actually send")
ap.add_argument("--repo", help="owner/name, to fill the default query")
ap.add_argument("--offline", action="store_true",
help="audit the text only. No token, nothing sent.")
args = ap.parse_args()
if args.file:
document = Path(args.file).read_text(encoding="utf-8")
else:
document = 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 args.repo:
try:
owner, name = args.repo.split("/", 1)
except ValueError:
log.error("--repo takes owner/name")
return 2
variables.setdefault("owner", owner)
variables.setdefault("name", name)
why_not = refusal(document)
if why_not:
log.error("refusing to send: %s", why_not)
return 2
findings = audit(document, variables)
log.info("point cost: %d point(s). The ceiling is checked against the query "
"text and the variables you supply; a document rejected during "
"validation never executes and is not billed at all.",
point_cost(not args.offline))
for f in findings:
log.info(" %s.%s written %s value %s %s %s",
f["field"], f["arg"], f["written"],
"?" if f["value"] is None else f["value"],
f["source"],
"OVER, needs %d pages" % f["pages"]
if f["verdict"] == "over-ceiling" else f["verdict"])
state, detail = classify(findings)
log.info("%s: %s", state, detail)
log.info("repair: %s", repair(state))
probe = None
if not args.offline:
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN, or pass --offline to audit the text only")
return 2
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, document, variables)
phase = error_phase(status, body)
arg, field = offending_argument(body)
log.info("HTTP %s, phase=%s, data key present=%s",
status, phase, "yes" if isinstance(body, dict) and "data" in body else "no")
if arg:
log.info("rejected argument: %s on field %s", arg, field)
if phase == "validation":
log.info("validation-rejected: the body carries errors and no data "
"key, which is what a failure before execution looks like")
probe = {"status": status, "phase": phase,
"rejected_argument": arg, "rejected_field": field}
print(json.dumps({"ceiling": CEILING, "state": state, "findings": findings,
"probe": probe}, indent=2, default=str))
return 1 if state not in ("within-the-ceiling", "no-slicing-argument") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Resolve every first and last in a GraphQL document against the ceiling of 100.
*
* 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.
*
* Environment:
* GITHUB_TOKEN a token with read access. Not needed with GITHUB_OFFLINE.
* GITHUB_QUERY the document as a string
* GITHUB_VARIABLES JSON object of the variables you actually send
* GITHUB_REPO owner/name, to fill the default query
* GITHUB_OFFLINE set to 1 to audit the text only
*/
const API = 'https://api.github.com';
const UA = 'github-graphql-slice/1.0';
/** The ceiling on first and last, on every connection in the schema. */
export const CEILING = 100;
/** A simple query costs one point; a validation rejection costs none. */
export const POINTS_PER_QUERY = 1;
const DEFAULT_QUERY = 'query($owner: String!, $name: String!, $first: Int = 250) {'
+ ' repository(owner: $owner, name: $name) {'
+ ' issues(first: $first, states: OPEN) { totalCount nodes { number title } }'
+ ' } }';
const ARGUMENT_IN_MESSAGE = /Argument '([A-Za-z_][A-Za-z0-9_]*)' on Field '([A-Za-z_][A-Za-z0-9_]*)'/;
/** Remove GraphQL comments and string literals from a document. Pure. */
export function stripNoise(document) {
const src = String(document ?? '');
const out = [];
let i = 0;
while (i < src.length) {
const ch = src[i];
if (ch === '#') {
while (i < src.length && src[i] !== '\n') i += 1;
continue;
}
if (src.startsWith('"""', i)) {
const j = src.indexOf('"""', i + 3);
i = j < 0 ? src.length : j + 3;
out.push(' ');
continue;
}
if (ch === '"') {
i += 1;
while (i < src.length && src[i] !== '"') i += src[i] === '\\' ? 2 : 1;
i += 1;
out.push(' ');
continue;
}
out.push(ch);
i += 1;
}
return out.join('');
}
/** The top-level operations in a document, in order. Pure. */
export function operations(document) {
const src = `${stripNoise(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;
}
/** The text written for one named argument, or null. Pure. */
export function argumentValue(argumentText, name) {
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);
for (const part of parts) {
const at = part.indexOf(':');
if (at < 0) continue;
if (part.slice(0, at).trim() === name) {
return part.slice(at + 1).trim() || null;
}
}
return null;
}
/** Defaults declared in the operation's variable definitions. Pure. */
export function variableDefaults(document) {
const head = stripNoise(document).split('{')[0];
const out = {};
for (const part of head.replace(/\(/g, ' ').replace(/\)/g, ' ').split(',')) {
const at = part.indexOf(':');
if (at < 0) continue;
// The first part still carries the operation keyword and name in front
// of the variable, so take the last token rather than the whole thing.
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;
}
/** Every first and last in the document, with the field carrying it. Pure. */
export function slicingArguments(document) {
const src = stripNoise(document);
const out = [];
let i = 0;
let depth = 0;
let word = '';
while (i < src.length) {
const ch = src[i];
if (/[A-Za-z0-9_]/.test(ch)) { word += ch; i += 1; continue; }
if (ch === '(' && word) {
const field = word;
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;
}
const args = src.slice(i + 1, j);
for (const arg of ['first', 'last']) {
const raw = argumentValue(args, arg);
if (raw !== null) out.push({ field, arg, raw, depth });
}
i = j + 1;
word = '';
continue;
}
if (ch === '{') depth += 1;
else if (ch === '}') depth = Math.max(0, depth - 1);
word = '';
i += 1;
}
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'];
}
/** One resolved value against the ceiling. Pure. */
export function verdict(value) {
if (value === null || value === undefined) return 'unresolved';
if (value < 1) return 'below-one';
if (value > CEILING) return 'over-ceiling';
if (value === CEILING) return 'at-ceiling';
return 'under-ceiling';
}
/** Round trips at 100 per page for a requested size. Pure. */
export function pagesNeeded(value) {
if (value === null || value === undefined || value < 1) return null;
return Math.ceil(value / CEILING);
}
/** Every slicing argument, resolved and judged. Pure. */
export function audit(document, variables) {
const defaults = variableDefaults(document);
return slicingArguments(document).map((found) => {
const [value, source] = resolveSlice(found.raw, defaults, variables);
return {
field: found.field,
arg: found.arg,
depth: found.depth,
written: found.raw,
value,
source,
verdict: verdict(value),
pages: pagesNeeded(value),
};
});
}
/** Classify a whole document. Pure. Returns [state, detail]. */
export function classify(findings) {
if (!findings || findings.length === 0) {
return ['no-slicing-argument', 'no first or last appears anywhere in this '
+ 'document. GitHub requires a slicing argument on every connection, so '
+ 'either there is no connection here or the query is rejected for a '
+ 'different reason than this note describes.'];
}
const over = findings.filter((f) => f.verdict === 'over-ceiling');
const literal = over.filter((f) => f.source === 'literal');
if (literal.length) {
const f = literal[0];
return ['over-ceiling-in-the-document',
`${f.field}.${f.arg} asks for ${f.value}, which is over the ceiling of `
+ `${CEILING}, and the number is written in the query.`];
}
if (over.length) {
const f = over[0];
return ['over-ceiling-through-a-variable',
`${f.field}.${f.arg} resolves to ${f.value} through a ${f.source}, so a `
+ `search of the document for a number over ${CEILING} finds nothing and `
+ 'every call is still rejected.'];
}
const unresolved = findings.filter((f) => f.verdict === 'unresolved');
if (unresolved.length) {
const f = unresolved[0];
return ['unresolved-slice',
`${f.field}.${f.arg} is written as ${f.written} and no default or `
+ 'supplied value explains it, so this document cannot be cleared from '
+ 'the text alone.'];
}
const below = findings.filter((f) => f.verdict === 'below-one');
if (below.length) {
const f = below[0];
return ['slice-below-one',
`${f.field}.${f.arg} resolves to ${f.value}. The range is 1 to ${CEILING} `
+ 'and zero is rejected the same way an oversized value is.'];
}
return ['within-the-ceiling',
`all ${findings.length} slicing argument(s) resolve to between 1 and `
+ `${CEILING}. This document is not rejected for an argument value; the `
+ 'node count is a separate question.'];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state) {
if (state === 'over-ceiling-in-the-document') {
return 'set the value to 100 and page with after: $cursor until '
+ 'pageInfo.hasNextPage is false. The ceiling is not adjustable.';
}
if (state === 'over-ceiling-through-a-variable') {
return 'fix the value where it is set, not in the query text. Cap it at '
+ '100 in the caller or in the variable default, and page with '
+ 'after: $cursor for the rest.';
}
if (state === 'unresolved-slice') {
return 'run this again with the variables so the value can be resolved. An '
+ 'argument nobody can resolve is not an argument anybody has checked.';
}
if (state === 'slice-below-one') {
return 'use a value of at least 1. A slicing argument of 0 is not a cheap '
+ 'query, it is a rejected one.';
}
if (state === 'within-the-ceiling') {
return 'nothing on the argument ceiling. Check the node count as well: see '
+ '/github/graphql-node-limit-exceeded/ -- a document legal on every '
+ 'argument can still be rejected for the product of them.';
}
return 'point the check at a document containing a connection.';
}
/** Which phase of the request failed. Pure. */
export function errorPhase(status, body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return 'unreadable';
if (!(Array.isArray(body.errors) && body.errors.length > 0)) return 'clean';
if (!Object.prototype.hasOwnProperty.call(body, 'data')) return 'validation';
return 'execution';
}
/** The [argument, field] the server named, or [null, null]. Pure. */
export function offendingArgument(body) {
if (!body || typeof body !== 'object' || !Array.isArray(body.errors)) return [null, null];
for (const err of body.errors) {
const message = (err && typeof err === 'object' && err.message) || '';
const m = ARGUMENT_IN_MESSAGE.exec(message);
if (m) return [m[1], m[2]];
}
return [null, null];
}
/** Points this run can spend. Pure. */
export function pointCost(sending) {
return sending ? POINTS_PER_QUERY : 0;
}
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 offline = process.env.GITHUB_OFFLINE === '1';
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_REPO) {
const [owner, name] = process.env.GITHUB_REPO.split('/');
if (!owner || !name) {
console.error('GITHUB_REPO takes owner/name');
process.exitCode = 2;
return;
}
if (!('owner' in variables)) variables.owner = owner;
if (!('name' in variables)) variables.name = name;
}
const whyNot = refusal(document);
if (whyNot) {
console.error(`refusing to send: ${whyNot}`);
process.exitCode = 2;
return;
}
const findings = audit(document, variables);
console.log(`point cost: ${pointCost(!offline)} point(s). A document rejected `
+ 'during validation never executes and is not billed at all.');
for (const f of findings) {
console.log(` ${f.field}.${f.arg} written ${f.written} value `
+ `${f.value === null ? '?' : f.value} ${f.source} `
+ `${f.verdict === 'over-ceiling' ? `OVER, needs ${f.pages} pages` : f.verdict}`);
}
const [state, detail] = classify(findings);
console.log(`${state}: ${detail}`);
console.log(`repair: ${repair(state)}`);
let probe = null;
if (!offline) {
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.error('set GITHUB_TOKEN, or GITHUB_OFFLINE=1 to audit the text only');
process.exitCode = 2;
return;
}
const { status, body } = await runQuery(token, document, variables);
const phase = errorPhase(status, body);
const [arg, field] = offendingArgument(body);
const hasData = !!body && typeof body === 'object'
&& Object.prototype.hasOwnProperty.call(body, 'data');
console.log(`HTTP ${status}, phase=${phase}, data key present=${hasData ? 'yes' : 'no'}`);
if (arg) console.log(`rejected argument: ${arg} on field ${field}`);
if (phase === 'validation') {
console.log('validation-rejected: the body carries errors and no data key, '
+ 'which is what a failure before execution looks like');
}
probe = { status, phase, rejected_argument: arg, rejected_field: field };
}
console.log(JSON.stringify({ ceiling: CEILING, state, findings, probe }, null, 2));
process.exitCode = (state === 'within-the-ceiling' || state === 'no-slicing-argument') ? 0 : 1;
}
// 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 suite is mostly about where a number comes from, because that is where this bug lives. A literal over the ceiling, a variable default over the ceiling, a supplied variable that overrides a safe default with an unsafe one, and a variable nobody can resolve are four separate cases with four separate verdicts. After that: that a variable definition reading $first: Int = 250 is never mistaken for an argument called first, that last is treated exactly like first, that a validation failure with no data key is told apart from an execution failure that has one, and that the offline path spends nothing.
from github_graphql_slice import (
CEILING, POINTS_PER_QUERY, argument_value, audit, classify, error_phase,
offending_argument, operations, pages_needed, point_cost, refusal, repair,
resolve_slice, slicing_arguments, variable_defaults, verdict,
)
LITERAL = "query { repository(owner: \"a\", name: \"b\") { issues(first: 500) { totalCount } } }"
VIA_DEFAULT = ("query($first: Int = 250) { repository(owner: \"a\", name: \"b\")"
" { issues(first: $first) { totalCount } } }")
SAFE = ("query($first: Int = 100) { repository(owner: \"a\", name: \"b\")"
" { issues(first: $first) { totalCount } } }")
VALIDATION_BODY = {"errors": [{"message": "Argument 'first' on Field 'issues' has an "
"invalid value (500). Expected type 'Int'."}]}
EXECUTION_BODY = {"data": {"repository": None},
"errors": [{"type": "NOT_FOUND", "message": "Could not resolve"}]}
def test_the_ceiling_is_one_hundred_everywhere():
assert CEILING == 100
assert verdict(101) == "over-ceiling"
assert verdict(100) == "at-ceiling"
assert verdict(1) == "under-ceiling"
assert verdict(0) == "below-one"
assert verdict(None) == "unresolved"
def test_a_literal_over_the_ceiling_is_found_in_the_text():
found = audit(LITERAL, {})
assert [(f["field"], f["arg"], f["value"], f["source"]) for f in found] == [
("issues", "first", 500, "literal")]
state, detail = classify(found)
assert state == "over-ceiling-in-the-document"
assert "500" in detail
def test_a_variable_default_over_the_ceiling_is_invisible_to_a_grep():
assert "250" not in "".join(f["written"] for f in audit(VIA_DEFAULT, {}))
found = audit(VIA_DEFAULT, {})
assert found[0]["value"] == 250
assert found[0]["source"] == "variable-default"
state, detail = classify(found)
assert state == "over-ceiling-through-a-variable"
assert "finds nothing" in detail
def test_a_supplied_variable_beats_the_default_because_the_server_sees_it():
found = audit(SAFE, {"first": 400})
assert found[0]["value"] == 400
assert found[0]["source"] == "variable-supplied"
assert classify(found)[0] == "over-ceiling-through-a-variable"
assert classify(audit(SAFE, {}))[0] == "within-the-ceiling"
def test_an_unresolved_variable_is_never_assumed_safe():
doc = "query($n: Int!) { repository(owner: \"a\", name: \"b\") { issues(first: $n) { totalCount } } }"
found = audit(doc, {})
assert found[0]["source"] == "unresolved"
assert found[0]["verdict"] == "unresolved"
state = classify(found)[0]
assert state == "unresolved-slice"
assert "--variables" in repair(state)
def test_a_variable_definition_is_not_an_argument_called_first():
assert variable_defaults(VIA_DEFAULT) == {"$first": "250"}
args = slicing_arguments(VIA_DEFAULT)
assert len(args) == 1
assert args[0]["field"] == "issues"
assert argument_value("$first: Int = 250", "first") is None
assert argument_value("first: 100, states: OPEN", "first") == "100"
def test_last_is_treated_exactly_like_first():
doc = "query { repository(owner: \"a\", name: \"b\") { issues(last: 250) { totalCount } } }"
found = audit(doc, {})
assert found[0]["arg"] == "last"
assert classify(found)[0] == "over-ceiling-in-the-document"
def test_the_word_first_inside_a_string_is_not_an_argument():
doc = 'query { search(query: "first: 500", type: ISSUE, first: 10) { issueCount } }'
found = audit(doc, {})
assert [(f["arg"], f["value"]) for f in found] == [("first", 10)]
assert classify(found)[0] == "within-the-ceiling"
def test_a_clean_document_is_sent_on_to_the_node_count_rather_than_cleared():
state = classify(audit(SAFE, {}))[0]
assert state == "within-the-ceiling"
assert "graphql-node-limit-exceeded" in repair(state)
def test_the_pages_that_number_really_means():
assert pages_needed(500) == 5
assert pages_needed(101) == 2
assert pages_needed(100) == 1
assert pages_needed(0) is None
assert pages_needed(None) is None
def test_a_validation_failure_carries_no_data_key_at_all():
assert error_phase(200, VALIDATION_BODY) == "validation"
assert error_phase(200, EXECUTION_BODY) == "execution"
assert error_phase(200, {"data": {"repository": {"name": "x"}}}) == "clean"
assert error_phase(200, None) == "unreadable"
def test_the_server_names_the_argument_and_the_field():
assert offending_argument(VALIDATION_BODY) == ("first", "issues")
assert offending_argument(EXECUTION_BODY) == (None, None)
assert offending_argument(None) == (None, 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("") == "the document contains no operation to send."
assert refusal(LITERAL) is None
def test_the_offline_audit_spends_nothing():
assert POINTS_PER_QUERY == 1
assert point_cost(False) == 0
assert point_cost(True) == 1
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CEILING, POINTS_PER_QUERY, argumentValue, audit, classify, errorPhase,
offendingArgument, operations, pagesNeeded, pointCost, refusal, repair,
slicingArguments, variableDefaults, verdict,
} from './github-graphql-slice.mjs';
const LITERAL = 'query { repository(owner: "a", name: "b") { issues(first: 500) { totalCount } } }';
const VIA_DEFAULT = 'query($first: Int = 250) { repository(owner: "a", name: "b")'
+ ' { issues(first: $first) { totalCount } } }';
const SAFE = 'query($first: Int = 100) { repository(owner: "a", name: "b")'
+ ' { issues(first: $first) { totalCount } } }';
const VALIDATION_BODY = {
errors: [{ message: "Argument 'first' on Field 'issues' has an invalid value (500)." }],
};
const EXECUTION_BODY = {
data: { repository: null },
errors: [{ type: 'NOT_FOUND', message: 'Could not resolve' }],
};
test('the ceiling is one hundred everywhere', () => {
assert.equal(CEILING, 100);
assert.equal(verdict(101), 'over-ceiling');
assert.equal(verdict(100), 'at-ceiling');
assert.equal(verdict(1), 'under-ceiling');
assert.equal(verdict(0), 'below-one');
assert.equal(verdict(null), 'unresolved');
});
test('a literal over the ceiling is found in the text', () => {
const found = audit(LITERAL, {});
assert.deepEqual(found.map((f) => [f.field, f.arg, f.value, f.source]),
[['issues', 'first', 500, 'literal']]);
const [state, detail] = classify(found);
assert.equal(state, 'over-ceiling-in-the-document');
assert.match(detail, /500/);
});
test('a variable default over the ceiling is invisible to a grep', () => {
const found = audit(VIA_DEFAULT, {});
assert.ok(!found.map((f) => f.written).join('').includes('250'));
assert.equal(found[0].value, 250);
assert.equal(found[0].source, 'variable-default');
const [state, detail] = classify(found);
assert.equal(state, 'over-ceiling-through-a-variable');
assert.match(detail, /finds nothing/);
});
test('a supplied variable beats the default because the server sees it', () => {
const found = audit(SAFE, { first: 400 });
assert.equal(found[0].value, 400);
assert.equal(found[0].source, 'variable-supplied');
assert.equal(classify(found)[0], 'over-ceiling-through-a-variable');
assert.equal(classify(audit(SAFE, {}))[0], 'within-the-ceiling');
});
test('an unresolved variable is never assumed safe', () => {
const doc = 'query($n: Int!) { repository(owner: "a", name: "b") { issues(first: $n) { totalCount } } }';
const found = audit(doc, {});
assert.equal(found[0].source, 'unresolved');
assert.equal(found[0].verdict, 'unresolved');
const state = classify(found)[0];
assert.equal(state, 'unresolved-slice');
assert.match(repair(state), /variables/);
});
test('a variable definition is not an argument called first', () => {
assert.deepEqual(variableDefaults(VIA_DEFAULT), { $first: '250' });
const args = slicingArguments(VIA_DEFAULT);
assert.equal(args.length, 1);
assert.equal(args[0].field, 'issues');
assert.equal(argumentValue('$first: Int = 250', 'first'), null);
assert.equal(argumentValue('first: 100, states: OPEN', 'first'), '100');
});
test('last is treated exactly like first', () => {
const doc = 'query { repository(owner: "a", name: "b") { issues(last: 250) { totalCount } } }';
const found = audit(doc, {});
assert.equal(found[0].arg, 'last');
assert.equal(classify(found)[0], 'over-ceiling-in-the-document');
});
test('the word first inside a string is not an argument', () => {
const doc = 'query { search(query: "first: 500", type: ISSUE, first: 10) { issueCount } }';
const found = audit(doc, {});
assert.deepEqual(found.map((f) => [f.arg, f.value]), [['first', 10]]);
assert.equal(classify(found)[0], 'within-the-ceiling');
});
test('a clean document is sent on to the node count rather than cleared', () => {
const state = classify(audit(SAFE, {}))[0];
assert.equal(state, 'within-the-ceiling');
assert.match(repair(state), /graphql-node-limit-exceeded/);
});
test('the pages that number really means', () => {
assert.equal(pagesNeeded(500), 5);
assert.equal(pagesNeeded(101), 2);
assert.equal(pagesNeeded(100), 1);
assert.equal(pagesNeeded(0), null);
assert.equal(pagesNeeded(null), null);
});
test('a validation failure carries no data key at all', () => {
assert.equal(errorPhase(200, VALIDATION_BODY), 'validation');
assert.equal(errorPhase(200, EXECUTION_BODY), 'execution');
assert.equal(errorPhase(200, { data: { repository: { name: 'x' } } }), 'clean');
assert.equal(errorPhase(200, null), 'unreadable');
});
test('the server names the argument and the field', () => {
assert.deepEqual(offendingArgument(VALIDATION_BODY), ['first', 'issues']);
assert.deepEqual(offendingArgument(EXECUTION_BODY), [null, null]);
assert.deepEqual(offendingArgument(null), [null, 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(''), 'the document contains no operation to send.');
assert.equal(refusal(LITERAL), null);
});
test('the offline audit spends nothing', () => {
assert.equal(POINTS_PER_QUERY, 1);
assert.equal(pointCost(false), 0);
assert.equal(pointCost(true), 1);
});
FAQ
Can I raise the limit above 100 with a header or a preview?
No. The cap on first and last is part of the connection contract in GitHub's schema and applies to every connection in it. There is no scope, header, API version, preview or Enterprise plan that changes it, and anything you find suggesting otherwise is describing a different GraphQL API. The only way to read more than 100 items from a connection is to ask for 100 and then follow the cursor, which is the design rather than a workaround.
Why did the same number work fine against the REST API?
Because REST clamps and GraphQL rejects. Sending per_page=200 to a REST list endpoint returns 100 items with no error and no warning, so a client that asked for 200 believes it received everything, which is a quieter bug than this one and has its own note. GraphQL refusing outright is the better of the two behaviours. It is only surprising because most people meet the clamping version first and learn from it that an oversized page size is harmless.
The query has no number over 100 anywhere in it. Where is the 500 coming from?
From a variable, almost certainly. A slicing value can be written as a literal, given a default in the operation's variable definitions, or supplied by the caller in the variables map, and only the first of those is in the document. This script resolves all three and prints which one each value came from, because that is what tells you which file to open. A value the caller computes at run time is not knowable from the repository at all, so it is reported as unresolved rather than assumed to be fine.
Does a rejected query cost points?
No. Rejection happens during validation, before execution begins, so the query never runs and nothing is deducted from the hourly point budget. That makes it the cheapest failure in this section and it also means a retry loop stuck on it will not drain your quota, which is unusual here. It is still an infinite loop that returns nothing, and it will fail identically forever, because the document is the problem and retrying does not change the document.
I set every first to 100 and the query is still rejected. What now?
Then you have the other limit. Node count multiplies down through the nesting, so 100 repositories each with 100 pull requests each with 100 comments asks for over a million nodes against a cap of 500,000, and every individual argument in that document is legal. That is a different error, MAX_NODE_LIMIT_EXCEEDED, and a different note. The two checks are worth running together: this one clears each argument on its own, and the node count clears the product of them.
Related field notes
- The node count multiplies down the nesting
- Inner connections truncate per parent
- REST clamps per_page instead of rejecting
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.
- Resource limitations — GitHub GraphQL API
- GraphQL specification: Validation
- GraphQL specification: Values of Correct Type
- Using pagination in the REST API — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.