Diagnostic GitHub API
every request 403s because there is no User-Agent header
403 {"message":"Request forbidden by administrative rules. Please make sure your request has a User-Agent header."} — and it happens on the REST root, which any anonymous caller can read. Nobody looks at the body, because 403 in this API almost always means quota or permissions, and both of those theories send you somewhere the answer is not.
GitHub requires a User-Agent header on every API request and refuses the ones that arrive without it. That check runs before authentication, which is why the refusal lands on endpoints that need no token and why widening the token changes nothing.
So the first move is not a request, it is a sort. A 403 from this API is one of four things: the User-Agent rule, primary quota exhaustion, a secondary limit, or a permission. Exactly one of them names itself in the response body, and it is this one. The script below classifies the 403 from the body and the headers, then grades the User-Agent the client actually put on the wire — which it reads off its own request object rather than off a second request — and prints a replacement string that names your application and a way to reach you.
The problem in plain words
The status code is the trap. Everybody in this section has been trained that 403 means rate limit or permissions, so the first twenty minutes go to GET /rate_limit, which reports five thousand requests remaining, and the next twenty go to the repository settings page, which shows the account as an admin. Both readings are correct and neither is relevant.
Then the credential gets blamed. The token is re-minted, and re-minted wider, and the failure does not move, because the request never got as far as the credential. A token with every box ticked is refused exactly as fast as no token at all, which leaves behind a permanently over-broad credential and an unsolved problem.
It shows up in a specific place, too, which is why it feels like it comes out of nowhere. Every serious GitHub SDK sets a User-Agent for you, so this never happens while you are prototyping with Octokit or PyGithub. It happens the day somebody drops to a raw socket, a shell script, a Lambda runtime with a hand-built request, or a language's bare standard-library HTTP client — the layer under the SDK, where nothing is set for you.
Why it happens
The rule is applied before authentication. This is the single most useful fact here. A request with no User-Agent is refused whether it carries a perfect token, an expired one or none, and it is refused on paths that have no permission model at all. If a call to the REST root 403s, no amount of work on the credential can matter, because the credential was never consulted.
The body names the rule in words. GitHub does not leave you guessing on this one: the message asks for a User-Agent header by name. That makes it the only 403 in this section you can identify from a single response without a control request, a header pair or a second endpoint — provided somebody logs the body, which is the part that usually goes missing.
A library default satisfies the rule and fails the intent. python-requests/2.31.0 is a User-Agent, so the request works. It also describes several million other callers, so when your integration starts behaving badly there is nothing for GitHub to identify but the address it came from. The documented ask is for the header to name the application or the account operating it, and the difference only ever matters on the day somebody needs to reach you.
It is a default, not a per-request argument. A header applied at each call site survives exactly as long as nobody adds a new call site. Set it once on the session, the client instance or the transport, so the request that forgets it cannot be constructed.
What a script cannot tell you is what the network did. A header that your process set and a proxy stripped looks, from GitHub's side, exactly like a header you never set. The one reading that distinguishes them is on your own machine: what the HTTP client recorded as sent, after redirects and after every interceptor. That is a property of the request object, not of the response, and it costs nothing to look at.
The fix, as a flow
One request, and the half of the answer that matters does not come back over the wire at all: the client already holds the headers it sent and can read them off its own request object. So the script sorts the refusal by the sentence in its body, then grades the User-Agent it actually put on the wire, and prints a replacement string that names the application and a way to reach you.
How to fix it
Read the body of the 403 before anything else
Not the status, the body. If the message mentions administrative rules or asks for a User-Agent, you are done diagnosing and the rest of this page is about the repair. If it says Resource not accessible by integration or mentions a secondary rate limit, this is not your problem and the script will say so rather than guessing.
Confirm the 403 is not quota, in the same response
x-ratelimit-remaining rides on the refusal. A value above zero rules out primary exhaustion without a second call. A value of zero sends you to the core quota note, and no header will help there.
Ask your own client what it actually sent
After the request, the header set is on the request object: response.request.headers in requests, the object you passed in fetch. This is the wire truth after redirects and interceptors, and it separates "we never set it" from "we set it and something removed it". Those have different repairs and only one of them is in your code.
Set a descriptive default on the client, not on the call
acme-repo-auditor/1.2 (+https://acme.example/contact): the application, its version, and somewhere to reach whoever runs it. Put it on the session or transport so the header is present on requests nobody has written yet.
Re-run the same path and check the grade, not just the status
A 200 with python-requests/2.31.0 is a pass on the rule and a fail on the point of it. The script reports that state separately, because it is the one that will still be true in a year if you only ever look at status codes.
How to check it worked
Two runs against the same path. The first reproduces the refusal by removing the header entirely; the second sets a descriptive one and shows the request succeeding without any credential at all, which is the clearest possible demonstration that the credential was never the subject.
python3 github_user_agent_403.py --path / --no-user-agent
# / returned 403
# user-agent sent: none
# user-agent-missing: the body names the rule: GitHub requires a User-Agent
# header on every API request and refuses the ones that arrive without it.
python3 github_user_agent_403.py --path / --app "Acme Repo Auditor" \
--contact https://acme.example/contact
# / returned 200
# user-agent sent: acme-repo-auditor/1.0 (+https://acme.example/contact)
# user-agent-ok: the request succeeded and the header identifies the caller.
The full code
One GET, and the half of the answer that matters never comes back over the wire: the client reads what it sent off its own request object. Everything that produces a finding is pure — a classifier that sorts a 403 by the sentence in its body, a grader that tells an absent header from a library default from a descriptive one, and a builder for the replacement string. The reproduction flag is the only part that needs the network, and it is a GET to a path any anonymous caller may read.
"""Sort a GitHub 403 by cause, then grade the User-Agent the client sent.
Read only. One GET, to a path you choose, defaulting to the REST root, which
any anonymous caller may read. Nothing here writes, nothing needs a scope, and
the credential is optional because the rule this note is about is applied
before the credential is looked at.
GitHub requires a User-Agent header on every API request. Requests without one
are refused with a 403 whose body names the rule. That makes it the only 403 in
this API you can identify from a single response, and the easiest one to
mistake for the other three, all of which produce the same status code.
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_user_agent_403")
API = "https://api.github.com"
# The User-Agent strings HTTP clients supply when nobody sets one. They satisfy
# the rule, so the request works. They also describe several million other
# callers, which is the half of the ask they fail: the documented request is
# for a header naming the application or the account behind it.
LIBRARY_DEFAULTS = (
"python-requests/", "python-urllib/", "urllib3/", "python-httpx/", "httpx/",
"go-http-client/", "node-fetch/", "undici", "node/", "axios/", "got (",
"okhttp/", "java/", "apache-httpclient/", "curl/", "libcurl/", "wget/",
"httpie/", "postmanruntime/", "restsharp/", "guzzlehttp/", "faraday",
"ruby/", "php/", "dart/", "reqwest/", "http.rb/", "python/",
)
def _has_version(text):
"""True when some token in the string looks like name/1.2. Pure helper."""
for part in str(text).split():
if "/" in part:
tail = part.split("/", 1)[1].lstrip("vV")
if tail[:1].isdigit():
return True
return False
def grade_user_agent(value):
"""Grade a User-Agent string. Pure.
Five grades, because "absent" and "present but useless" are different
findings with different urgencies: one is why the request is being refused
right now, the other is why nobody will be able to reach you later.
"""
if value is None:
return ("absent",
"no User-Agent header at all. GitHub refuses the request "
"before it considers the credential, so this fails on "
"endpoints that need no credential.")
text = str(value).strip()
if not text:
return ("empty",
"the header is present with an empty value, which is refused "
"exactly as if it had never been set.")
low = text.lower()
for prefix in LIBRARY_DEFAULTS:
if low.startswith(prefix):
return ("library-default",
"the header names the HTTP library rather than your "
"integration. The request works; nobody at GitHub can "
"tell your traffic from anyone else's using that library.")
has_version = _has_version(text)
has_contact = "http" in low or "@" in text
if has_version and has_contact:
return ("descriptive",
"names the application, a version and a way to reach you. "
"Nothing to change.")
if has_version or has_contact:
return ("named",
"identifies the caller, but only halfway. Add whichever half "
"is missing: a version, or a URL or address to reach you at.")
return ("opaque",
"present and custom, but it names nothing anyone could act on. "
"Add a version and a contact.")
def classify_403(message, headers):
"""Sort a 403 into the four things it means on this API. Pure.
Order matters. The User-Agent rule names itself, so it is checked first and
never inferred. A secondary limit says so in the body. Primary exhaustion
says so in a header rather than in words. Everything else is authorization,
and none of it is repaired by a header.
"""
text = (message or "").lower()
head = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
if "user-agent" in text or "administrative rules" in text:
return ("user-agent-rule",
"the body names the rule: GitHub requires a User-Agent header "
"on every API request and refuses the ones that arrive "
"without it.")
if "secondary rate limit" in text or "abuse detection" in text:
return ("secondary-rate-limit",
"a secondary limit, which is about the shape of the traffic "
"rather than the number of requests. Slow down and honour "
"retry-after; no header changes this.")
if head.get("x-ratelimit-remaining") == "0":
return ("primary-rate-limit",
"x-ratelimit-remaining is zero, so this is the hourly quota "
"and the reset time is on the same response.")
if "saml" in text or "single sign-on" in text or "sso" in text:
return ("sso-enforcement",
"an organization enforcing SSO is hiding the resource from a "
"credential that has not been authorized for it.")
if ("not accessible by integration" in text or "must have admin" in text
or "resource not accessible" in text or "permission" in text):
return ("permission",
"an authorization refusal: the credential reached GitHub, was "
"accepted, and is not allowed to do this.")
if "ip address" in text or "allow list" in text or "allowlist" in text:
return ("ip-allow-list",
"an organization IP allow list refused the source address. "
"The repair is a network conversation, not a code change.")
return ("unclassified-403",
"the body does not match any of the shapes this script knows. "
"Read it literally; it is the most specific thing you have.")
def verdict(status, message, headers, user_agent_sent):
"""Combine a status, a body message and what the client actually sent. Pure.
A successful request is still worth a finding. The rule is satisfied by any
non-empty string, so a 200 proves nothing about whether the header names
anything, and that is the state that survives for years unnoticed.
"""
grade, detail = grade_user_agent(user_agent_sent)
if status == 403:
cause, why = classify_403(message, headers)
if cause == "user-agent-rule":
return ("user-agent-missing",
"%s What the client actually sent: %s."
% (why, "nothing"
if grade in ("absent", "empty") else repr(user_agent_sent)))
return (cause, "%s This is a 403, but not the one this page is about, "
"and no User-Agent will repair it." % why)
if status == 401:
return ("not-a-user-agent-problem",
"a 401 means a credential was received and refused, or was "
"required and never arrived. The User-Agent rule answers 403 "
"and never 401.")
if status >= 400:
return ("other-failure",
"status %d, which the User-Agent rule does not produce. The "
"header that was sent grades as %s." % (status, grade))
if grade in ("descriptive", "named"):
return ("user-agent-ok",
"the request succeeded and the header identifies the caller. "
"%s" % detail)
return ("identifiable-agent-missing",
"the request succeeded, so the rule itself is satisfied, but %s"
% detail)
def suggest_user_agent(app, version="1.0", contact=None):
"""Build the replacement header value. Pure.
Deliberately boring: a slug, a version, and a contact in the parenthesised
form GitHub's own examples use. The value of this function is that it
always produces something that grades as descriptive.
"""
slug = "".join(c if c.isalnum() else "-" for c in str(app).lower())
while "--" in slug:
slug = slug.replace("--", "-")
slug = slug.strip("-") or "unnamed-integration"
agent = "%s/%s" % (slug, version)
if contact:
agent += " (+%s)" % contact
return agent
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--path", default="/",
help="the API path that was refused. The default is the "
"REST root, which any anonymous caller may read")
ap.add_argument("--app", default="",
help="the name of your integration, for the suggested header")
ap.add_argument("--contact", default="",
help="a URL or address GitHub could use to reach you")
ap.add_argument("--no-user-agent", action="store_true",
help="reproduce the refusal by removing the header entirely")
args = ap.parse_args()
session = requests.Session()
session.headers.update({
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
})
if args.no_user_agent:
# requests drops a header set to None rather than falling back to its
# own default, which is the only way to actually reproduce this from a
# client that is trying to be helpful.
session.headers["User-Agent"] = None
elif args.app:
session.headers["User-Agent"] = suggest_user_agent(
args.app, contact=args.contact or None)
token = os.environ.get("GITHUB_TOKEN")
if token:
session.headers["Authorization"] = "Bearer " + token
else:
log.info("no GITHUB_TOKEN set, which is fine: the User-Agent rule is "
"applied before authentication, so an anonymous request "
"demonstrates it exactly as well")
url = API + args.path if args.path.startswith("/") else args.path
response = session.get(url, timeout=30)
try:
body = response.json()
except ValueError:
body = None
message = body.get("message") if isinstance(body, dict) else None
headers = {k.lower(): v for k, v in response.headers.items()}
# The authoritative reading of what went on the wire, after redirects and
# after whatever the library added. It lives on the request object the
# client already holds, so it needs no second request and no control.
sent = response.request.headers.get("User-Agent")
log.info("%s returned %d", args.path, response.status_code)
log.info("user-agent sent: %s", sent if sent else "none")
log.info("body message: %s", message or "none")
log.info("remaining quota: %s", headers.get("x-ratelimit-remaining", "not reported"))
state, detail = verdict(response.status_code, message, headers, sent)
log.info("%s: %s", state, detail)
if state in ("user-agent-missing", "identifiable-agent-missing"):
want = suggest_user_agent(args.app or "your integration",
contact=args.contact or "https://example.com/contact")
log.info("repair: set this once on the session, client or transport, "
"never per request: User-Agent: %s", want)
log.info("repair: a request that forgets the header should be "
"impossible to construct, not merely rare.")
print(json.dumps({"path": args.path, "status": response.status_code,
"user_agent_sent": sent, "message": message,
"state": state}, indent=2))
return 1 if state in ("user-agent-missing", "identifiable-agent-missing") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Sort a GitHub 403 by cause, then grade the User-Agent the client sent.
*
* Read only. One GET, defaulting to the REST root, which any anonymous caller
* may read. The credential is optional, because the rule this note is about is
* applied before the credential is looked at.
*
* One honest difference from the Python version: Node's fetch supplies a
* default User-Agent of its own when you omit the header, so the reproduction
* flag here cannot actually produce a request with no header. What this script
* grades is the header it configured, which is what your code controls.
*/
const API = 'https://api.github.com';
/** The User-Agent strings HTTP clients supply when nobody sets one. */
export const LIBRARY_DEFAULTS = [
'python-requests/', 'python-urllib/', 'urllib3/', 'python-httpx/', 'httpx/',
'go-http-client/', 'node-fetch/', 'undici', 'node/', 'axios/', 'got (',
'okhttp/', 'java/', 'apache-httpclient/', 'curl/', 'libcurl/', 'wget/',
'httpie/', 'postmanruntime/', 'restsharp/', 'guzzlehttp/', 'faraday',
'ruby/', 'php/', 'dart/', 'reqwest/', 'http.rb/', 'python/',
];
/** True when some token in the string looks like name/1.2. Pure. */
function hasVersion(text) {
for (const part of String(text).split(/\s+/)) {
if (part.includes('/')) {
const tail = part.slice(part.indexOf('/') + 1).replace(/^[vV]+/, '');
if (tail.length && tail[0] >= '0' && tail[0] <= '9') return true;
}
}
return false;
}
/** Grade a User-Agent string. Pure. Five grades, not two. */
export function gradeUserAgent(value) {
if (value === null || value === undefined) {
return ['absent',
'no User-Agent header at all. GitHub refuses the request before it ' +
'considers the credential, so this fails on endpoints that need no ' +
'credential.'];
}
const text = String(value).trim();
if (!text) {
return ['empty',
'the header is present with an empty value, which is refused exactly ' +
'as if it had never been set.'];
}
const low = text.toLowerCase();
for (const prefix of LIBRARY_DEFAULTS) {
if (low.startsWith(prefix)) {
return ['library-default',
'the header names the HTTP library rather than your integration. The ' +
'request works; nobody at GitHub can tell your traffic from anyone ' +
"else's using that library."];
}
}
const version = hasVersion(text);
const contact = low.includes('http') || text.includes('@');
if (version && contact) {
return ['descriptive',
'names the application, a version and a way to reach you. Nothing to change.'];
}
if (version || contact) {
return ['named',
'identifies the caller, but only halfway. Add whichever half is ' +
'missing: a version, or a URL or address to reach you at.'];
}
return ['opaque',
'present and custom, but it names nothing anyone could act on. Add a ' +
'version and a contact.'];
}
/** Sort a 403 into the four things it means on this API. Pure. */
export function classify403(message, headers) {
const text = String(message ?? '').toLowerCase();
const head = {};
for (const [k, v] of Object.entries(headers ?? {})) head[String(k).toLowerCase()] = String(v);
if (text.includes('user-agent') || text.includes('administrative rules')) {
return ['user-agent-rule',
'the body names the rule: GitHub requires a User-Agent header on every ' +
'API request and refuses the ones that arrive without it.'];
}
if (text.includes('secondary rate limit') || text.includes('abuse detection')) {
return ['secondary-rate-limit',
'a secondary limit, which is about the shape of the traffic rather ' +
'than the number of requests. Slow down and honour retry-after; no ' +
'header changes this.'];
}
if (head['x-ratelimit-remaining'] === '0') {
return ['primary-rate-limit',
'x-ratelimit-remaining is zero, so this is the hourly quota and the ' +
'reset time is on the same response.'];
}
if (text.includes('saml') || text.includes('single sign-on') || text.includes('sso')) {
return ['sso-enforcement',
'an organization enforcing SSO is hiding the resource from a ' +
'credential that has not been authorized for it.'];
}
if (text.includes('not accessible by integration') || text.includes('must have admin')
|| text.includes('resource not accessible') || text.includes('permission')) {
return ['permission',
'an authorization refusal: the credential reached GitHub, was ' +
'accepted, and is not allowed to do this.'];
}
if (text.includes('ip address') || text.includes('allow list') || text.includes('allowlist')) {
return ['ip-allow-list',
'an organization IP allow list refused the source address. The repair ' +
'is a network conversation, not a code change.'];
}
return ['unclassified-403',
'the body does not match any of the shapes this script knows. Read it ' +
'literally; it is the most specific thing you have.'];
}
/** Combine a status, a body message and what the client actually sent. Pure. */
export function verdict(status, message, headers, userAgentSent) {
const [grade, detail] = gradeUserAgent(userAgentSent);
if (status === 403) {
const [cause, why] = classify403(message, headers);
if (cause === 'user-agent-rule') {
const shown = (grade === 'absent' || grade === 'empty')
? 'nothing' : JSON.stringify(userAgentSent);
return ['user-agent-missing', `${why} What the client actually sent: ${shown}.`];
}
return [cause, `${why} This is a 403, but not the one this page is ` +
'about, and no User-Agent will repair it.'];
}
if (status === 401) {
return ['not-a-user-agent-problem',
'a 401 means a credential was received and refused, or was required ' +
'and never arrived. The User-Agent rule answers 403 and never 401.'];
}
if (status >= 400) {
return ['other-failure',
`status ${status}, which the User-Agent rule does not produce. The ` +
`header that was sent grades as ${grade}.`];
}
if (grade === 'descriptive' || grade === 'named') {
return ['user-agent-ok',
`the request succeeded and the header identifies the caller. ${detail}`];
}
return ['identifiable-agent-missing',
`the request succeeded, so the rule itself is satisfied, but ${detail}`];
}
/** Build the replacement header value. Pure. */
export function suggestUserAgent(app, version = '1.0', contact = null) {
let slug = String(app).toLowerCase().split('').map(
(c) => (/[a-z0-9]/.test(c) ? c : '-')).join('');
while (slug.includes('--')) slug = slug.replaceAll('--', '-');
slug = slug.replace(/^-+|-+$/g, '') || 'unnamed-integration';
let agent = `${slug}/${version}`;
if (contact) agent += ` (+${contact})`;
return agent;
}
function arg(name, fallback) {
const i = process.argv.indexOf(name);
return i === -1 ? fallback : process.argv[i + 1];
}
async function main() {
const path = arg('--path', '/');
const app = arg('--app', '');
const contact = arg('--contact', '');
const strip = process.argv.includes('--no-user-agent');
const headers = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};
if (!strip && app) headers['User-Agent'] = suggestUserAgent(app, '1.0', contact || null);
const token = process.env.GITHUB_TOKEN;
if (token) {
headers.Authorization = `Bearer ${token}`;
} else {
console.log('no GITHUB_TOKEN set, which is fine: the User-Agent rule is ' +
'applied before authentication, so an anonymous request demonstrates it ' +
'exactly as well');
}
const url = path.startsWith('/') ? API + path : path;
const res = await fetch(url, { headers });
let body = null;
try { body = await res.json(); } catch { body = null; }
const message = body && typeof body === 'object' ? body.message ?? null : null;
const seen = {};
for (const [k, v] of res.headers.entries()) seen[k.toLowerCase()] = v;
// What this script configured, which is the part your code controls. Node
// adds a default of its own when the key is absent, so an absent key here
// does not mean an absent header on the wire.
const sent = headers['User-Agent'] ?? null;
console.log(`${path} returned ${res.status}`);
console.log(`user-agent configured: ${sent ?? 'none, so Node supplied its own'}`);
console.log(`body message: ${message ?? 'none'}`);
console.log(`remaining quota: ${seen['x-ratelimit-remaining'] ?? 'not reported'}`);
const [state, detail] = verdict(res.status, message, seen, sent);
console.log(`${state}: ${detail}`);
if (state === 'user-agent-missing' || state === 'identifiable-agent-missing') {
const want = suggestUserAgent(app || 'your integration', '1.0',
contact || 'https://example.com/contact');
console.log('repair: set this once on the client or transport, never per ' +
`request: User-Agent: ${want}`);
}
console.log(JSON.stringify({ path, status: res.status, userAgentSent: sent, message, state }, null, 2));
process.exitCode = (state === 'user-agent-missing' ||
state === 'identifiable-agent-missing') ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not fire a live request and set an exit code the suite then inherits.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The cases worth pinning are the ones that are awkward to produce on demand: a 403 that is quota rather than a header, a 403 that is a permission, a header that satisfies the rule and identifies nobody, and a request that succeeded while still being wrong. All four functions take strings and return tuples, so each of those is two lines and no network.
from github_user_agent_403 import (
classify_403, grade_user_agent, suggest_user_agent, verdict,
)
def test_an_absent_header_is_not_an_empty_one():
assert grade_user_agent(None)[0] == "absent"
assert grade_user_agent("")[0] == "empty"
assert grade_user_agent(" ")[0] == "empty"
def test_a_library_default_satisfies_the_rule_and_identifies_nobody():
assert grade_user_agent("python-requests/2.31.0")[0] == "library-default"
assert grade_user_agent("Go-http-client/1.1")[0] == "library-default"
assert grade_user_agent("curl/8.4.0")[0] == "library-default"
def test_a_named_application_with_a_version_and_a_contact_is_descriptive():
grade, _ = grade_user_agent("acme-repo-auditor/1.2 (+https://acme.example)")
assert grade == "descriptive"
def test_half_an_identity_is_reported_as_half():
assert grade_user_agent("acme-repo-auditor/1.2")[0] == "named"
assert grade_user_agent("acme (+https://acme.example)")[0] == "named"
assert grade_user_agent("auditor")[0] == "opaque"
def test_the_user_agent_rule_names_itself_in_the_body():
state, detail = classify_403(
"Request forbidden by administrative rules. Please make sure your "
"request has a User-Agent header.", {})
assert state == "user-agent-rule"
assert "User-Agent" in detail
def test_quota_exhaustion_is_read_from_a_header_not_from_words():
state, _ = classify_403("API rate limit exceeded",
{"X-RateLimit-Remaining": "0"})
assert state == "primary-rate-limit"
def test_a_secondary_limit_is_not_confused_with_the_primary_one():
state, _ = classify_403("You have exceeded a secondary rate limit",
{"x-ratelimit-remaining": "4998"})
assert state == "secondary-rate-limit"
def test_a_permission_refusal_is_sorted_away_from_this_page():
state, _ = classify_403("Resource not accessible by integration", {})
assert state == "permission"
def test_an_unfamiliar_403_is_admitted_rather_than_guessed():
assert classify_403("Something new", {})[0] == "unclassified-403"
def test_the_missing_header_verdict_says_what_was_actually_sent():
state, detail = verdict(
403, "Request forbidden by administrative rules. Please make sure "
"your request has a User-Agent header.", {}, None)
assert state == "user-agent-missing"
assert "nothing" in detail
def test_a_quota_403_is_not_reported_as_a_header_problem():
state, detail = verdict(403, "API rate limit exceeded",
{"x-ratelimit-remaining": "0"}, "acme/1.0 (+http://a)")
assert state == "primary-rate-limit"
assert "no User-Agent will repair it" in detail
def test_a_401_is_sent_to_the_credential_notes():
assert verdict(401, "Bad credentials", {}, "acme/1.0")[0] == "not-a-user-agent-problem"
def test_a_successful_request_with_a_default_agent_is_still_a_finding():
state, _ = verdict(200, None, {}, "python-requests/2.31.0")
assert state == "identifiable-agent-missing"
def test_a_successful_request_with_a_descriptive_agent_passes():
state, _ = verdict(200, None, {}, "acme-auditor/1.2 (+https://acme.example)")
assert state == "user-agent-ok"
def test_the_suggested_header_always_grades_as_descriptive():
agent = suggest_user_agent("Acme Repo Auditor!", "1.2",
"https://acme.example/contact")
assert agent == "acme-repo-auditor/1.2 (+https://acme.example/contact)"
assert grade_user_agent(agent)[0] == "descriptive"
def test_an_unnameable_application_still_produces_a_usable_header():
assert suggest_user_agent("!!!").startswith("unnamed-integration/")
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
classify403, gradeUserAgent, suggestUserAgent, verdict,
} from './github-user-agent-403.mjs';
test('an absent header is not an empty one', () => {
assert.equal(gradeUserAgent(null)[0], 'absent');
assert.equal(gradeUserAgent('')[0], 'empty');
assert.equal(gradeUserAgent(' ')[0], 'empty');
});
test('a library default satisfies the rule and identifies nobody', () => {
assert.equal(gradeUserAgent('python-requests/2.31.0')[0], 'library-default');
assert.equal(gradeUserAgent('Go-http-client/1.1')[0], 'library-default');
assert.equal(gradeUserAgent('curl/8.4.0')[0], 'library-default');
});
test('a named application with a version and a contact is descriptive', () => {
assert.equal(gradeUserAgent('acme-repo-auditor/1.2 (+https://acme.example)')[0],
'descriptive');
});
test('half an identity is reported as half', () => {
assert.equal(gradeUserAgent('acme-repo-auditor/1.2')[0], 'named');
assert.equal(gradeUserAgent('acme (+https://acme.example)')[0], 'named');
assert.equal(gradeUserAgent('auditor')[0], 'opaque');
});
test('the user-agent rule names itself in the body', () => {
const [state, detail] = classify403(
'Request forbidden by administrative rules. Please make sure your '
+ 'request has a User-Agent header.', {});
assert.equal(state, 'user-agent-rule');
assert.ok(detail.includes('User-Agent'));
});
test('quota exhaustion is read from a header not from words', () => {
assert.equal(classify403('API rate limit exceeded',
{ 'X-RateLimit-Remaining': '0' })[0], 'primary-rate-limit');
});
test('a secondary limit is not confused with the primary one', () => {
assert.equal(classify403('You have exceeded a secondary rate limit',
{ 'x-ratelimit-remaining': '4998' })[0], 'secondary-rate-limit');
});
test('a permission refusal is sorted away from this page', () => {
assert.equal(classify403('Resource not accessible by integration', {})[0],
'permission');
});
test('an unfamiliar 403 is admitted rather than guessed', () => {
assert.equal(classify403('Something new', {})[0], 'unclassified-403');
});
test('the missing header verdict says what was actually sent', () => {
const [state, detail] = verdict(403,
'Request forbidden by administrative rules. Please make sure your '
+ 'request has a User-Agent header.', {}, null);
assert.equal(state, 'user-agent-missing');
assert.ok(detail.includes('nothing'));
});
test('a quota 403 is not reported as a header problem', () => {
const [state, detail] = verdict(403, 'API rate limit exceeded',
{ 'x-ratelimit-remaining': '0' }, 'acme/1.0 (+http://a)');
assert.equal(state, 'primary-rate-limit');
assert.ok(detail.includes('no User-Agent will repair it'));
});
test('a 401 is sent to the credential notes', () => {
assert.equal(verdict(401, 'Bad credentials', {}, 'acme/1.0')[0],
'not-a-user-agent-problem');
});
test('a successful request with a default agent is still a finding', () => {
assert.equal(verdict(200, null, {}, 'python-requests/2.31.0')[0],
'identifiable-agent-missing');
});
test('a successful request with a descriptive agent passes', () => {
assert.equal(verdict(200, null, {}, 'acme-auditor/1.2 (+https://acme.example)')[0],
'user-agent-ok');
});
test('the suggested header always grades as descriptive', () => {
const agent = suggestUserAgent('Acme Repo Auditor!', '1.2',
'https://acme.example/contact');
assert.equal(agent, 'acme-repo-auditor/1.2 (+https://acme.example/contact)');
assert.equal(gradeUserAgent(agent)[0], 'descriptive');
});
test('an unnameable application still produces a usable header', () => {
assert.ok(suggestUserAgent('!!!').startsWith('unnamed-integration/'));
});
FAQ
Why does this fail on endpoints that need no authentication at all?
Because the User-Agent check runs before authentication is considered. The request is refused as malformed rather than as unauthorized, so the presence, absence or breadth of a token has no effect on it. That is also the fastest way to confirm the diagnosis: if the REST root, which any anonymous caller can read, answers 403, the problem cannot be about permissions or scopes, because no permission was ever consulted.
My SDK works and my curl one-liner does not. Is that the same problem?
Almost certainly. Every serious GitHub SDK sets a User-Agent for you, and curl sets one too, so the pure form of this failure needs a client that sets nothing at all: a raw socket, a hand-built request in a Lambda runtime, a language's bare standard-library HTTP client, or a proxy configuration that strips unknown headers on the way out. The tell is the sentence in the body, which asks for the header by name whatever produced the request.
Does GitHub care what the User-Agent says, or only that it exists?
The rule only checks that it exists, so any non-empty string makes the 403 go away. The documented ask is different from the enforced rule: GitHub asks for the header to name your application or your account. That difference has no effect until the day your integration is behaving badly and somebody at GitHub wants to tell you, at which point python-requests/2.31.0 identifies several million callers and none of them are reachable.
Is this the same as being rate limited?
No, and the response tells you so in two places. A quota 403 carries x-ratelimit-remaining: 0 and a reset timestamp; a secondary limit says secondary rate limit in the body and usually carries retry-after. The User-Agent 403 carries neither, and its body asks for a header. Sorting on the body first is what stops the investigation going to the rate limit endpoint, which will cheerfully report five thousand requests remaining while every call fails.
I set the header and it still is not arriving. Where did it go?
Ask your own HTTP client rather than GitHub. The request object records what was actually sent after redirects and interceptors, so response.request.headers in requests, or the object you passed to fetch, is the wire truth. A header set in your code and absent there was removed by something between the two: an interceptor, a redirect that rebuilt the request, or an egress proxy. That is a different repair from adding the header, and only one of them is in your source.
Related field notes
- A permission error disguised as 404 Not Found
- The core hourly quota is exhausted
- 401 Bad credentials on every endpoint
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.
- Getting started with the REST API — GitHub Docs
- Troubleshooting the REST API — GitHub Docs
- Rate limits for the REST API — GitHub Docs
- Authenticating to 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.