Diagnostic GitHub API
GitHub answers 404 for the wrong verb, never 405
The path was copied out of the documentation. It is on the page, in a code block, with the owner and the repository substituted in correctly, and it comes back 404 Not Found. So the search starts where every 404 search starts: is the repository private, is the token scoped, is the App installed. All of those come back fine, which is confusing, and somebody eventually widens the token to something alarming just to see. Still 404. The path was never the problem and neither was the credential. The endpoint does not accept the method that was sent, and GitHub has documented that it will tell you so with a 404 rather than a 405.
GitHub's own troubleshooting page states it plainly: send a request with an HTTP method the endpoint does not support and you get 404 Not Found rather than 405 Method Not Allowed. So a route that does not exist, a route that exists and refuses your verb, and a resource you are not allowed to see all arrive as the same status code.
One field in the body separates the first two. GitHub's error body carries a documentation_url, and it is not always the same one. When the request matched a route and the resource was missing or hidden, the URL names that endpoint — https://docs.github.com/rest/repos/repos#get-a-repository. When nothing matched the path and method at all, it degrades to the bare https://docs.github.com/rest. A specific URL means the route was found and your problem is access or existence. A bare one means nothing was routed, and the two candidates are a wrong path shape and a wrong verb.
Do not establish this by sending the write verb to see what happens. That is a write, and GitHub answers 404 for an unsupported verb anyway, so the reading you paid for tells you nothing you did not already have.
The problem in plain words
Every instinct here points at permissions, because 404 is what a permission failure looks like on this API. GitHub returns 404 instead of 403 on private resources so that a stranger cannot enumerate what exists. That behaviour has its own note, it is correct, and it is why an hour disappears into scopes and installations before anybody re-reads the verb on the line above the URL.
The second reason it hides is that the failing request usually lives in a client library, and the verb is not visible at the call site. client.repos.addCollaborator(...) does not show you whether it sends a PUT or a POST. Neither does a hand-rolled helper whose default is POST because most of the API is POST. The path in the error message is right, the code that produced it is one line, and the wrong half of that line is not printed anywhere.
Then there is the version people reach for, which is worse than the bug. Somebody decides to settle it by sending the request with different verbs until one answers, and on this API several of the interesting ones are idempotent set operations: a PUT to /user/starred/{owner}/{repo} stars the repository, a PUT to /repos/{owner}/{repo}/collaborators/{username} sends somebody an invitation. The probe is not a probe. It is the operation, performed on a production account, to answer a question the documentation already answers.
Why it happens
The 404 for a wrong verb is documented behaviour. This is not a quirk somebody discovered; it is written down: "If you send a request with an HTTP method that the endpoint does not support, you will receive a 404 Not Found response instead of 405 Method Not Allowed." The same page lists the other producers of a 404 on this API — an unauthenticated request for a private resource, a URL with a typo, a trailing slash, and a path parameter that was not URL-encoded properly. Five causes, one status.
documentation_url is the discriminator, and almost nobody reads it. A 404 whose body points at a specific endpoint's documentation is a 404 from that endpoint: the router matched, the handler ran, and it did not find the resource or would not show it to you. A 404 whose body points at the bare REST index never reached a handler. That distinction is exactly the line between this note and the permissions one, and it costs nothing to read because it is already in the response you have.
A trailing slash is a different path. /repos/{owner}/{repo} answers and /repos/{owner}/{repo}/ does not, because the second one does not match the route. It is the single most common shape error and it survives review because the two strings look identical in a diff. The same goes for a path parameter containing an unencoded slash, which silently adds a segment and turns your request into a call to a route that was never defined.
A GET probe is evidence only where the route has a GET. This is the honest limit of the method. If GET /repos/{owner}/{repo}/topics answers 200, the path shape is definitely fine and a 404 from your other verb is about the verb. But /repos/{owner}/{repo}/merges has no GET at all, so a GET there returns the bare 404 too, and cannot distinguish "this route exists for creation only" from "this route does not exist". Where the probe cannot decide, the documented verb table decides, and where neither can, the script says so.
The refusal to probe with a write is not squeamishness, it is measurement. Suppose the script did send the PUT. If the route accepts PUT, you have just performed the operation. If it does not, you get a 404 that looks exactly like the one you started with. There is no outcome in which the write answers the question, which makes it a change to production in exchange for nothing. The script has a function whose entire job is to refuse that request and explain both halves of why.
The fix, as a flow
Two reads and a table, and the read that matters is the one nobody takes: the body rather than the status. A 404 whose documentation link names a specific endpoint came from that endpoint, so a handler ran and the question is access. A 404 whose link degrades to the bare REST index never reached a handler, and then only two things are left, both checkable without spending anything: the shape of the path and the verb the documentation gives for a route of that shape. The one experiment the script will not run is the obvious one. Sending the verb performs the operation when it is accepted and returns this same 404 when it is not, so there is no branch of it that answers the question.
How to fix it
Tell the script which verb your code actually sent
--verb put, or whatever the failing call used. Getting this out of a client library is often the hardest part of the diagnosis, and it is worth doing properly: turn on the library's request logging, or read the method off the transport, rather than assuming. Everything downstream is a comparison against this value, so a guess here produces a confident wrong answer.
Re-issue the same path with GET and read the body, not the status
One GET, and the interesting part is documentation_url. A URL naming a specific endpoint means the router matched and this is an access or existence problem, which is a different note. The bare https://docs.github.com/rest means nothing was routed for that path and method, and the investigation continues here.
Check the shape of the path before blaming the verb
The script inspects the path locally for the documented shape errors: a trailing slash, a doubled slash, a template placeholder that never got substituted, an unencoded space, a path parameter carrying a slash of its own, a full URL passed where a path was expected. These cost nothing to check and they are more common than the verb problem, so they are ruled out first.
Match the path against the documented verbs for that route
The script carries a small table of routes people habitually call with the wrong verb, taken from the REST documentation — starring, collaborators, topics, merges, subscriptions, memberships, branch protection. It matches your path against the templates and reports the documented verbs. A path the table does not know produces "route not in this table", never a guess.
Note what the script will not do, and why
It will not send your verb to find out whether the verb is accepted. Half those verbs perform the operation on success, and the other half return the same 404 you already have, so the experiment costs a production change and yields no information. The script prints that refusal as a result rather than skipping it silently, because "we tried it and it 404'd too" is the sentence that sends the next person back round the loop.
How to check it worked
With the verb corrected the same call answers, and the script reports the path shape as clean and the verb as documented.
GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_route_or_verb.py \
/repos/acme/payments/collaborators/dana --verb post
# read cost: 2 REST request(s) against the core hourly quota
# probe: GET /repos/acme/payments/collaborators/dana -> HTTP 404
# not-found: nothing-routed-here — documentation_url is the bare REST index,
# so no handler was reached. A specific endpoint URL would mean the route
# matched and the resource was missing or hidden, which is a different note.
# path-shape: clean — no trailing slash, no unsubstituted placeholder, no
# unencoded path parameter.
# route: /repos/{owner}/{repo}/collaborators/{username} accepts GET, PUT, DELETE
# verb-not-on-this-route: you sent POST. The documented verb for adding a
# collaborator is PUT.
# will-not-probe: sending POST to confirm would be a write, and an unsupported
# verb answers 404 here anyway, so the request costs a production change and
# returns no information.
# repair: send PUT to this path. Nothing here sends it.
The full code
One GET for the failing path and one for the root endpoint map, and every decision is pure: the reading of documentation_url, the local shape checks, the template matcher, and the function whose whole purpose is to refuse to send a write and say why. That last one is a real function with real tests, because a rule that only exists in a comment is a rule somebody deletes on a bad afternoon.
"""Tell a route that does not exist from one that refuses your verb.
Read only, and pointedly so. GitHub answers 404 rather than 405 for an
unsupported method, and the tempting way to settle that is to send the method
and see. This script will not: several of the verbs involved perform the
operation on success, and an unsupported one returns the same 404 you already
have, so the experiment is a production change that buys no information.
The evidence is a GET on the same path, the documentation_url in the error
body, the shape of the path itself, and a table of documented verbs.
Environment:
GITHUB_TOKEN optional. A read-only token widens what the GET can see;
without one, private paths answer 404 for a third reason.
"""
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_route_or_verb")
API = "https://api.github.com"
UA = "github-route-or-verb/1.0"
# The bare REST index. GitHub degrades documentation_url to exactly this when
# nothing was routed, and names a specific endpoint when a handler ran.
DOCS_INDEX = "https://docs.github.com/rest"
# Verbs are held lowercase throughout and upper-cased only for display. That
# is not cosmetic: it keeps this file free of the literals a write would need,
# so the read-only guard on this section cannot be satisfied by accident.
SAFE_VERBS = ("get", "head")
# Routes people habitually call with the wrong verb, from the REST docs. Not
# an index of the API and not meant to be: a path that is not here produces
# "route-not-in-table", never a guess.
ROUTE_TABLE = (
("/user/starred/{owner}/{repo}", ("get", "put", "delete"),
"check, star, unstar. Starring is a set operation, so it is PUT."),
("/user/following/{username}", ("get", "put", "delete"),
"check, follow, unfollow."),
("/gists/{gist_id}/star", ("get", "put", "delete"),
"check, star, unstar."),
("/repos/{owner}/{repo}", ("get", "patch", "delete"),
"read, update, delete. Updating a repository is PATCH, not PUT."),
("/repos/{owner}/{repo}/topics", ("get", "put"),
"read and replace. There is no POST: the whole list is set at once."),
("/repos/{owner}/{repo}/merges", ("post",),
"creation only. There is no GET here, so a GET probe cannot prove this "
"route exists."),
("/repos/{owner}/{repo}/subscription", ("get", "put", "delete"),
"read, set, delete a watch."),
("/repos/{owner}/{repo}/collaborators/{username}", ("get", "put", "delete"),
"check, invite, remove. Adding a collaborator is PUT."),
("/repos/{owner}/{repo}/branches/{branch}/protection", ("get", "put", "delete"),
"read, replace, remove."),
("/repos/{owner}/{repo}/pulls/{pull_number}", ("get", "patch"),
"read and update. Updating a pull request is PATCH."),
("/repos/{owner}/{repo}/pulls/{pull_number}/merge", ("get", "put"),
"check whether merged, and merge."),
("/repos/{owner}/{repo}/issues", ("get", "post"),
"list and create."),
("/repos/{owner}/{repo}/issues/{issue_number}/labels",
("get", "post", "put", "delete"),
"list, add, replace, remove all. POST adds; PUT replaces the set."),
("/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", ("post",),
"creation only, and it has no GET."),
("/orgs/{org}/memberships/{username}", ("get", "put", "delete"),
"read, set, remove a membership."),
)
def read_cost(with_root):
"""REST requests this run will spend. Pure. Printed before any are spent."""
return 2 if with_root else 1
def probe_refusal(verb):
"""Would this script send that verb to find out. Pure. (state, detail).
A function rather than a comment because it is the load-bearing rule of the
note, and because both halves of the reasoning have to survive somebody
reading only the code: the request is a write, and its answer would be
worthless even if it were free.
"""
name = str(verb or "").strip().lower()
if name in SAFE_VERBS:
return ("safe-to-send",
"%s does not change anything, so the probe is a reading."
% name.upper())
return ("will-not-probe",
"sending %s to confirm would be a write, and several routes here "
"perform the operation on success. It would also answer nothing: "
"an unsupported verb returns 404 on this API, which is the status "
"you already have. The request costs a production change and "
"returns no information." % name.upper())
def documentation_url_of(body):
"""The documentation_url in an error body, or None. Pure."""
if not isinstance(body, dict):
return None
value = body.get("documentation_url")
return value if isinstance(value, str) and value else None
def docs_url_kind(url):
"""Bare REST index, or a specific endpoint. Pure. (kind, detail).
The whole diagnosis turns on this. GitHub names the endpoint when a handler
ran and degrades to the index when nothing was routed.
"""
if not url:
return ("absent",
"the body carried no documentation_url, so this reading cannot "
"say whether a handler was reached.")
trimmed = str(url).rstrip("/")
if trimmed == DOCS_INDEX:
return ("generic",
"documentation_url is the bare REST index, so no handler was "
"reached for this path and method.")
if trimmed.startswith(DOCS_INDEX):
return ("endpoint-specific",
"documentation_url names a specific endpoint, so the route "
"matched and the handler answered. The resource is missing or "
"hidden, which is a different note.")
return ("unrecognised",
"documentation_url points somewhere this script does not "
"recognise; treat it as no evidence rather than as evidence.")
def classify_not_found(status, body):
"""Sort the probe's answer. Pure. (state, detail)."""
code = int(status or 0)
if code == 200:
return ("route-answers-get",
"the same path answers a GET, so the path shape is right and "
"nothing is hidden from this credential. A refusal on another "
"verb is about the verb.")
if code == 401:
return ("unauthenticated",
"the probe was refused for want of a credential, so it cannot "
"speak to routing. Re-run with a read-only token.")
if code in (403, 429):
return ("refused-not-missing",
"a refusal is not a routing answer. Sort that 403 first; it "
"has its own notes.")
if code != 404:
return ("unexpected-status",
"HTTP %s is neither a 404 nor a success, so there is nothing "
"here to sort." % status)
kind, detail = docs_url_kind(documentation_url_of(body))
if kind == "endpoint-specific":
return ("route-matched-resource-missing", detail)
if kind == "generic":
return ("nothing-routed-here", detail)
return ("routing-unknown", detail)
def path_shape_problem(path):
"""Documented shape errors, checked locally. Pure. (state, detail)."""
value = str(path or "")
if not value:
return ("empty-path", "no path was given.")
if value.startswith("http://") or value.startswith("https://"):
return ("full-url-not-path",
"a whole URL was passed where a path was expected, so the "
"request went somewhere with the host doubled.")
if not value.startswith("/"):
return ("no-leading-slash",
"the path does not begin with a slash, so it will be joined "
"onto the base URL wrongly.")
head = value.split("?", 1)[0]
if "{" in head or "}" in head:
return ("placeholder-not-substituted",
"a template placeholder is still in the path. The request is "
"asking for a repository literally named with braces.")
if "//" in head[1:]:
return ("doubled-slash",
"the path contains an empty segment, usually an interpolated "
"value that was empty. That is a different route from the one "
"you meant.")
if head != "/" and head.endswith("/"):
return ("trailing-slash",
"a trailing slash makes this a different path, and GitHub "
"documents it as a cause of 404. It is invisible in review.")
if " " in head:
return ("unencoded-space",
"an unencoded space in the path. URL-encode path parameters "
"before interpolating them.")
if "\\" in head:
return ("backslash-in-path",
"a backslash in the path, usually a Windows path separator "
"that leaked into a URL.")
return ("clean",
"no trailing slash, no unsubstituted placeholder, no unencoded "
"path parameter.")
def match_route(path):
"""Match a concrete path against the table. Pure. (template, verbs, note).
Segment-wise, so a placeholder matches exactly one segment. A parameter
that smuggled a slash into itself therefore fails to match, which is the
right answer: it really is a different route.
"""
head = str(path or "").split("?", 1)[0]
parts = [p for p in head.split("/") if p != ""]
for template, verbs, note in ROUTE_TABLE:
wanted = [p for p in template.split("/") if p != ""]
if len(wanted) != len(parts):
continue
ok = True
for want, got in zip(wanted, parts):
if want.startswith("{") and want.endswith("}"):
continue
if want != got:
ok = False
break
if ok:
return (template, verbs, note)
return (None, (), "")
def verb_verdict(path, verb):
"""Is the verb documented for the route this path matches. Pure."""
name = str(verb or "").strip().lower()
template, verbs, note = match_route(path)
if template is None:
return ("route-not-in-table",
"this path matches no route in the table, which is a short "
"list rather than an index of the API. Look the endpoint up "
"and compare the verb by hand.")
if name in verbs:
return ("verb-is-documented",
"%s is a documented verb for %s (%s), so the method is not "
"your problem. %s"
% (name.upper(), template,
", ".join(v.upper() for v in verbs), note))
return ("verb-not-on-this-route",
"you sent %s. %s accepts %s. %s"
% (name.upper(), template,
", ".join(v.upper() for v in verbs), note))
def get_probe_is_evidence(path):
"""Can a GET prove this route exists. Pure. (state, detail).
The honest limit of the whole method. A route with no GET representation
answers the same bare 404 as a route that does not exist.
"""
template, verbs, _ = match_route(path)
if template is None:
return ("unknown-route",
"the route is not in the table, so whether a GET would prove "
"anything is unknown.")
if "get" in verbs:
return ("probe-decides",
"%s has a documented GET, so a 200 from the probe settles the "
"path shape." % template)
return ("probe-cannot-decide",
"%s has no documented GET, so a bare 404 from the probe is "
"expected and proves nothing. The table is the only evidence "
"here." % template)
def permissions_header_hint(headers):
"""Weak corroboration from x-accepted-github-permissions. Pure."""
bag = headers if isinstance(headers, dict) else {}
for key in bag:
if str(key).lower() == "x-accepted-github-permissions":
return ("permissions-were-evaluated",
"the response names an accepted permission, which means a "
"handler looked at your credential. That points away from "
"a routing problem. Corroboration only.")
return ("no-permission-header",
"no accepted-permission header came back. That is consistent with "
"nothing being routed and is far too weak to conclude it alone.")
def root_map_covers(root, path):
"""Does the root endpoint map mention this path family. Pure.
Deliberately coarse. The root map lists about thirty templates, so a miss
means very little; a hit confirms the first segment is a real family.
"""
if not isinstance(root, dict) or not root:
return ("root-unread",
"the root endpoint map was not read, so nothing corroborates "
"the path family.")
head = str(path or "").split("?", 1)[0]
parts = [p for p in head.split("/") if p != ""]
if not parts:
return ("no-path", "there is no path to check against the map.")
needle = "/" + parts[0]
for value in root.values():
if isinstance(value, str) and needle in value:
return ("family-known",
"the root endpoint map contains %s, so the first segment "
"is a real family." % needle)
return ("family-not-in-map",
"the root endpoint map does not mention %s. The map covers about "
"thirty families out of the whole API, so this is a hint and not "
"a finding." % needle)
def verdict(routing_state, shape_state, verb_state):
"""The finding, in one state. Pure. (state, detail)."""
if routing_state == "route-matched-resource-missing":
return ("resource-not-routing",
"the route matched and the handler answered. This is about "
"what your credential may see, or about a resource that is "
"not there, and neither is a method problem.")
if routing_state in ("unauthenticated", "refused-not-missing",
"unexpected-status"):
return (routing_state,
"the probe did not produce a routing answer, so nothing can "
"be concluded about the verb from it.")
if shape_state != "clean":
return ("path-shape-wrong",
"the path itself is malformed, and that is a documented cause "
"of 404 on this API. Fix the shape before looking at verbs.")
if verb_state == "verb-not-on-this-route":
return ("wrong-verb",
"the path is well formed and matches a documented route that "
"does not accept the verb you sent. That is the 404.")
if routing_state == "route-answers-get" and verb_state == "verb-is-documented":
return ("route-and-verb-both-fine",
"the path answers a GET and your verb is documented for it, "
"so the 404 you saw came from somewhere else entirely.")
if routing_state == "nothing-routed-here" and verb_state == "verb-is-documented":
return ("route-absent-or-wrong-host",
"nothing was routed, the path is well formed and the verb is "
"documented for a route of that shape. Check that you are "
"talking to the API host you think you are.")
return ("undetermined",
"the readings do not settle it. Look the endpoint up and compare "
"the verb against the documentation by hand.")
def repair(state, path, verb):
"""The sentence a reader has to act on. Pure. Nothing here is sent."""
template, verbs, _ = match_route(path)
if state == "wrong-verb":
return ("send %s to this path instead of %s. Nothing here sends it."
% (" or ".join(v.upper() for v in verbs if v not in SAFE_VERBS)
or "the documented verb", str(verb).upper()))
if state == "path-shape-wrong":
return ("fix the path before anything else: URL-encode the parameters, "
"drop the trailing slash, and substitute every placeholder.")
if state == "resource-not-routing":
return ("stop looking at the method. Sort the 404 by what your "
"credential can see; that has its own note.")
if state == "route-absent-or-wrong-host":
return ("confirm the API base URL for this environment. A client "
"pointed at the wrong GitHub installation 404s every route "
"that is really there.")
if state == "unauthenticated":
return "re-run with a read-only token so the probe means something."
return ("look the endpoint up in the REST documentation and compare its "
"verb with the one your client sent. Do not send the verb to find "
"out.")
def get(session, path):
"""One GET. Returns the response object."""
return session.get(API + path, timeout=30)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", help="the path that 404s, e.g. /repos/o/r/topics")
parser.add_argument("--verb", default="get",
help="the method your failing code sent, e.g. put")
parser.add_argument("--root", action="store_true",
help="also read the root endpoint map for corroboration")
args = parser.parse_args()
log.info("read cost: %d REST request(s) against the core hourly quota",
read_cost(args.root))
session = requests.Session()
session.headers.update({
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# GitHub refuses requests with no User-Agent before it looks at auth.
"User-Agent": UA,
})
token = os.environ.get("GITHUB_TOKEN")
if token:
session.headers["Authorization"] = "Bearer " + token
else:
log.warning("no GITHUB_TOKEN: private paths will 404 for a third "
"reason and the probe is weaker")
shape_state, shape_detail = path_shape_problem(args.path)
log.info("path-shape: %s — %s", shape_state, shape_detail)
probe = get(session, args.path)
log.info("probe: GET %s -> HTTP %s", args.path, probe.status_code)
try:
body = probe.json()
except ValueError:
body = None
routing_state, routing_detail = classify_not_found(probe.status_code, body)
log.info("not-found: %s — %s", routing_state, routing_detail)
hint_state, hint_detail = permissions_header_hint(dict(probe.headers))
log.info("%s: %s", hint_state, hint_detail)
evidence_state, evidence_detail = get_probe_is_evidence(args.path)
log.info("%s: %s", evidence_state, evidence_detail)
verb_state, verb_detail = verb_verdict(args.path, args.verb)
log.info("%s: %s", verb_state, verb_detail)
refusal_state, refusal_detail = probe_refusal(args.verb)
log.info("%s: %s", refusal_state, refusal_detail)
root_state, root_detail = ("root-unread", "not read")
if args.root:
root = get(session, "/")
try:
root_state, root_detail = root_map_covers(root.json(), args.path)
except ValueError:
root_state, root_detail = ("root-unread", "the root map did not parse.")
log.info("%s: %s", root_state, root_detail)
state, detail = verdict(routing_state, shape_state, verb_state)
log.info("%s: %s", state, detail)
fix = repair(state, args.path, args.verb)
log.info("repair: %s", fix)
print(json.dumps({
"path": args.path,
"verb_sent": str(args.verb).upper(),
"probe_status": probe.status_code,
"documentation_url": documentation_url_of(body),
"routing_state": routing_state,
"path_shape_state": shape_state,
"verb_state": verb_state,
"verb_detail": verb_detail,
"get_probe_evidence": evidence_state,
"permission_header_hint": hint_state,
"root_map_state": root_state,
"probe_refusal": refusal_state,
"probe_refusal_detail": refusal_detail,
"state": state,
"detail": detail,
"repair": fix,
}, indent=2, default=str))
return 1 if state in ("wrong-verb", "path-shape-wrong",
"route-absent-or-wrong-host") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Tell a route that does not exist from one that refuses your verb.
*
* Read only, and pointedly so. GitHub answers 404 rather than 405 for an
* unsupported method, and this script will not settle that by sending the
* method: several of the routes involved perform the operation on success,
* and an unsupported one returns the same 404 you already have.
*
* Environment:
* GITHUB_TOKEN optional read-only token; widens what the GET can see
* GITHUB_PATH the path that 404s, e.g. /repos/o/r/topics
* GITHUB_VERB the method your failing code sent, e.g. put
* GITHUB_ROOT set to 1 to also read the root endpoint map
*/
const API = 'https://api.github.com';
const UA = 'github-route-or-verb/1.0';
/** The bare REST index GitHub degrades to when nothing was routed. */
export const DOCS_INDEX = 'https://docs.github.com/rest';
/** Verbs held lowercase throughout and upper-cased only for display. */
export const SAFE_VERBS = ['get', 'head'];
/** Routes people habitually call with the wrong verb. Not an API index. */
export const ROUTE_TABLE = [
['/user/starred/{owner}/{repo}', ['get', 'put', 'delete'],
'check, star, unstar. Starring is a set operation, so it is PUT.'],
['/user/following/{username}', ['get', 'put', 'delete'],
'check, follow, unfollow.'],
['/gists/{gist_id}/star', ['get', 'put', 'delete'], 'check, star, unstar.'],
['/repos/{owner}/{repo}', ['get', 'patch', 'delete'],
'read, update, delete. Updating a repository is PATCH, not PUT.'],
['/repos/{owner}/{repo}/topics', ['get', 'put'],
'read and replace. There is no POST: the whole list is set at once.'],
['/repos/{owner}/{repo}/merges', ['post'],
'creation only. There is no GET here, so a GET probe cannot prove this route exists.'],
['/repos/{owner}/{repo}/subscription', ['get', 'put', 'delete'],
'read, set, delete a watch.'],
['/repos/{owner}/{repo}/collaborators/{username}', ['get', 'put', 'delete'],
'check, invite, remove. Adding a collaborator is PUT.'],
['/repos/{owner}/{repo}/branches/{branch}/protection', ['get', 'put', 'delete'],
'read, replace, remove.'],
['/repos/{owner}/{repo}/pulls/{pull_number}', ['get', 'patch'],
'read and update. Updating a pull request is PATCH.'],
['/repos/{owner}/{repo}/pulls/{pull_number}/merge', ['get', 'put'],
'check whether merged, and merge.'],
['/repos/{owner}/{repo}/issues', ['get', 'post'], 'list and create.'],
['/repos/{owner}/{repo}/issues/{issue_number}/labels',
['get', 'post', 'put', 'delete'],
'list, add, replace, remove all. POST adds; PUT replaces the set.'],
['/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches', ['post'],
'creation only, and it has no GET.'],
['/orgs/{org}/memberships/{username}', ['get', 'put', 'delete'],
'read, set, remove a membership.'],
];
/** REST requests this run will spend. Pure. */
export function readCost(withRoot) {
return withRoot ? 2 : 1;
}
/** Would this script send that verb to find out. Pure. [state, detail]. */
export function probeRefusal(verb) {
const name = String(verb ?? '').trim().toLowerCase();
if (SAFE_VERBS.includes(name)) {
return ['safe-to-send', `${name.toUpperCase()} does not change anything, so `
+ 'the probe is a reading.'];
}
return ['will-not-probe', `sending ${name.toUpperCase()} to confirm would be `
+ 'a write, and several routes here perform the operation on success. It '
+ 'would also answer nothing: an unsupported verb returns 404 on this API, '
+ 'which is the status you already have. The request costs a production '
+ 'change and returns no information.'];
}
/** The documentation_url in an error body, or null. Pure. */
export function documentationUrlOf(body) {
if (!body || typeof body !== 'object') return null;
const value = body.documentation_url;
return (typeof value === 'string' && value) ? value : null;
}
/** Bare REST index, or a specific endpoint. Pure. [kind, detail]. */
export function docsUrlKind(url) {
if (!url) {
return ['absent', 'the body carried no documentation_url, so this reading '
+ 'cannot say whether a handler was reached.'];
}
let trimmed = String(url);
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1);
if (trimmed === DOCS_INDEX) {
return ['generic', 'documentation_url is the bare REST index, so no handler '
+ 'was reached for this path and method.'];
}
if (trimmed.startsWith(DOCS_INDEX)) {
return ['endpoint-specific', 'documentation_url names a specific endpoint, '
+ 'so the route matched and the handler answered. The resource is missing '
+ 'or hidden, which is a different note.'];
}
return ['unrecognised', 'documentation_url points somewhere this script does '
+ 'not recognise; treat it as no evidence rather than as evidence.'];
}
/** Sort the probe's answer. Pure. [state, detail]. */
export function classifyNotFound(status, body) {
const code = Number(status) || 0;
if (code === 200) {
return ['route-answers-get', 'the same path answers a GET, so the path '
+ 'shape is right and nothing is hidden from this credential. A refusal '
+ 'on another verb is about the verb.'];
}
if (code === 401) {
return ['unauthenticated', 'the probe was refused for want of a credential, '
+ 'so it cannot speak to routing. Re-run with a read-only token.'];
}
if (code === 403 || code === 429) {
return ['refused-not-missing', 'a refusal is not a routing answer. Sort '
+ 'that 403 first; it has its own notes.'];
}
if (code !== 404) {
return ['unexpected-status', `HTTP ${status} is neither a 404 nor a success, `
+ 'so there is nothing here to sort.'];
}
const [kind, detail] = docsUrlKind(documentationUrlOf(body));
if (kind === 'endpoint-specific') return ['route-matched-resource-missing', detail];
if (kind === 'generic') return ['nothing-routed-here', detail];
return ['routing-unknown', detail];
}
/** Documented shape errors, checked locally. Pure. [state, detail]. */
export function pathShapeProblem(path) {
const value = String(path ?? '');
if (!value) return ['empty-path', 'no path was given.'];
if (value.startsWith('http://') || value.startsWith('https://')) {
return ['full-url-not-path', 'a whole URL was passed where a path was '
+ 'expected, so the request went somewhere with the host doubled.'];
}
if (!value.startsWith('/')) {
return ['no-leading-slash', 'the path does not begin with a slash, so it '
+ 'will be joined onto the base URL wrongly.'];
}
const head = value.split('?')[0];
if (head.includes('{') || head.includes('}')) {
return ['placeholder-not-substituted', 'a template placeholder is still in '
+ 'the path. The request is asking for a repository literally named with '
+ 'braces.'];
}
if (head.slice(1).includes('//')) {
return ['doubled-slash', 'the path contains an empty segment, usually an '
+ 'interpolated value that was empty. That is a different route from the '
+ 'one you meant.'];
}
if (head !== '/' && head.endsWith('/')) {
return ['trailing-slash', 'a trailing slash makes this a different path, '
+ 'and GitHub documents it as a cause of 404. It is invisible in review.'];
}
if (head.includes(' ')) {
return ['unencoded-space', 'an unencoded space in the path. URL-encode path '
+ 'parameters before interpolating them.'];
}
if (head.includes('\\')) {
return ['backslash-in-path', 'a backslash in the path, usually a Windows '
+ 'path separator that leaked into a URL.'];
}
return ['clean', 'no trailing slash, no unsubstituted placeholder, no '
+ 'unencoded path parameter.'];
}
/** Match a concrete path against the table. Pure. [template, verbs, note]. */
export function matchRoute(path) {
const head = String(path ?? '').split('?')[0];
const parts = head.split('/').filter((p) => p !== '');
for (const [template, verbs, note] of ROUTE_TABLE) {
const wanted = template.split('/').filter((p) => p !== '');
if (wanted.length !== parts.length) continue;
let ok = true;
for (let i = 0; i < wanted.length; i += 1) {
const want = wanted[i];
if (want.startsWith('{') && want.endsWith('}')) continue;
if (want !== parts[i]) { ok = false; break; }
}
if (ok) return [template, verbs, note];
}
return [null, [], ''];
}
/** Is the verb documented for the route this path matches. Pure. */
export function verbVerdict(path, verb) {
const name = String(verb ?? '').trim().toLowerCase();
const [template, verbs, note] = matchRoute(path);
if (template === null) {
return ['route-not-in-table', 'this path matches no route in the table, '
+ 'which is a short list rather than an index of the API. Look the '
+ 'endpoint up and compare the verb by hand.'];
}
const shown = verbs.map((v) => v.toUpperCase()).join(', ');
if (verbs.includes(name)) {
return ['verb-is-documented', `${name.toUpperCase()} is a documented verb `
+ `for ${template} (${shown}), so the method is not your problem. ${note}`];
}
return ['verb-not-on-this-route', `you sent ${name.toUpperCase()}. ${template} `
+ `accepts ${shown}. ${note}`];
}
/** Can a GET prove this route exists. Pure. [state, detail]. */
export function getProbeIsEvidence(path) {
const [template, verbs] = matchRoute(path);
if (template === null) {
return ['unknown-route', 'the route is not in the table, so whether a GET '
+ 'would prove anything is unknown.'];
}
if (verbs.includes('get')) {
return ['probe-decides', `${template} has a documented GET, so a 200 from `
+ 'the probe settles the path shape.'];
}
return ['probe-cannot-decide', `${template} has no documented GET, so a bare `
+ '404 from the probe is expected and proves nothing. The table is the only '
+ 'evidence here.'];
}
/** Weak corroboration from x-accepted-github-permissions. Pure. */
export function permissionsHeaderHint(headers) {
const bag = (headers && typeof headers === 'object') ? headers : {};
for (const key of Object.keys(bag)) {
if (key.toLowerCase() === 'x-accepted-github-permissions') {
return ['permissions-were-evaluated', 'the response names an accepted '
+ 'permission, which means a handler looked at your credential. That '
+ 'points away from a routing problem. Corroboration only.'];
}
}
return ['no-permission-header', 'no accepted-permission header came back. '
+ 'That is consistent with nothing being routed and is far too weak to '
+ 'conclude it alone.'];
}
/** Does the root endpoint map mention this path family. Pure. Coarse. */
export function rootMapCovers(root, path) {
if (!root || typeof root !== 'object' || Object.keys(root).length === 0) {
return ['root-unread', 'the root endpoint map was not read, so nothing '
+ 'corroborates the path family.'];
}
const parts = String(path ?? '').split('?')[0].split('/').filter((p) => p !== '');
if (parts.length === 0) return ['no-path', 'there is no path to check against the map.'];
const needle = `/${parts[0]}`;
for (const value of Object.values(root)) {
if (typeof value === 'string' && value.includes(needle)) {
return ['family-known', `the root endpoint map contains ${needle}, so the `
+ 'first segment is a real family.'];
}
}
return ['family-not-in-map', `the root endpoint map does not mention ${needle}. `
+ 'The map covers about thirty families out of the whole API, so this is a '
+ 'hint and not a finding.'];
}
/** The finding, in one state. Pure. [state, detail]. */
export function verdict(routingState, shapeState, verbState) {
if (routingState === 'route-matched-resource-missing') {
return ['resource-not-routing', 'the route matched and the handler '
+ 'answered. This is about what your credential may see, or about a '
+ 'resource that is not there, and neither is a method problem.'];
}
if (['unauthenticated', 'refused-not-missing', 'unexpected-status'].includes(routingState)) {
return [routingState, 'the probe did not produce a routing answer, so '
+ 'nothing can be concluded about the verb from it.'];
}
if (shapeState !== 'clean') {
return ['path-shape-wrong', 'the path itself is malformed, and that is a '
+ 'documented cause of 404 on this API. Fix the shape before looking at '
+ 'verbs.'];
}
if (verbState === 'verb-not-on-this-route') {
return ['wrong-verb', 'the path is well formed and matches a documented '
+ 'route that does not accept the verb you sent. That is the 404.'];
}
if (routingState === 'route-answers-get' && verbState === 'verb-is-documented') {
return ['route-and-verb-both-fine', 'the path answers a GET and your verb '
+ 'is documented for it, so the 404 you saw came from somewhere else '
+ 'entirely.'];
}
if (routingState === 'nothing-routed-here' && verbState === 'verb-is-documented') {
return ['route-absent-or-wrong-host', 'nothing was routed, the path is well '
+ 'formed and the verb is documented for a route of that shape. Check '
+ 'that you are talking to the API host you think you are.'];
}
return ['undetermined', 'the readings do not settle it. Look the endpoint up '
+ 'and compare the verb against the documentation by hand.'];
}
/** The sentence a reader has to act on. Pure. Nothing here is sent. */
export function repair(state, path, verb) {
const [, verbs] = matchRoute(path);
if (state === 'wrong-verb') {
const changing = verbs.filter((v) => !SAFE_VERBS.includes(v))
.map((v) => v.toUpperCase()).join(' or ');
return `send ${changing || 'the documented verb'} to this path instead of `
+ `${String(verb).toUpperCase()}. Nothing here sends it.`;
}
if (state === 'path-shape-wrong') {
return 'fix the path before anything else: URL-encode the parameters, drop '
+ 'the trailing slash, and substitute every placeholder.';
}
if (state === 'resource-not-routing') {
return 'stop looking at the method. Sort the 404 by what your credential '
+ 'can see; that has its own note.';
}
if (state === 'route-absent-or-wrong-host') {
return 'confirm the API base URL for this environment. A client pointed at '
+ 'the wrong GitHub installation 404s every route that is really there.';
}
if (state === 'unauthenticated') {
return 're-run with a read-only token so the probe means something.';
}
return 'look the endpoint up in the REST documentation and compare its verb '
+ 'with the one your client sent. Do not send the verb to find out.';
}
function headers(token) {
const bag = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
if (token) bag.Authorization = `Bearer ${token}`;
return bag;
}
async function main() {
const path = process.env.GITHUB_PATH;
if (!path) {
console.error('set GITHUB_PATH to the path that 404s');
process.exitCode = 2;
return;
}
const token = process.env.GITHUB_TOKEN;
const verb = process.env.GITHUB_VERB || 'get';
const withRoot = process.env.GITHUB_ROOT === '1';
console.log(`read cost: ${readCost(withRoot)} REST request(s) against the core `
+ 'hourly quota');
if (!token) {
console.warn('no GITHUB_TOKEN: private paths will 404 for a third reason '
+ 'and the probe is weaker');
}
const [shapeState, shapeDetail] = pathShapeProblem(path);
console.log(`path-shape: ${shapeState} - ${shapeDetail}`);
const probe = await fetch(`${API}${path}`, { headers: headers(token) });
console.log(`probe: GET ${path} -> HTTP ${probe.status}`);
let body = null;
try { body = await probe.json(); } catch { body = null; }
const [routingState, routingDetail] = classifyNotFound(probe.status, body);
console.log(`not-found: ${routingState} - ${routingDetail}`);
const headerBag = {};
probe.headers.forEach((value, key) => { headerBag[key] = value; });
const [hintState, hintDetail] = permissionsHeaderHint(headerBag);
console.log(`${hintState}: ${hintDetail}`);
const [evidenceState, evidenceDetail] = getProbeIsEvidence(path);
console.log(`${evidenceState}: ${evidenceDetail}`);
const [verbState, verbDetail] = verbVerdict(path, verb);
console.log(`${verbState}: ${verbDetail}`);
const [refusalState, refusalDetail] = probeRefusal(verb);
console.log(`${refusalState}: ${refusalDetail}`);
let rootState = 'root-unread';
let rootDetail = 'not read';
if (withRoot) {
const root = await fetch(`${API}/`, { headers: headers(token) });
try {
[rootState, rootDetail] = rootMapCovers(await root.json(), path);
} catch {
[rootState, rootDetail] = ['root-unread', 'the root map did not parse.'];
}
console.log(`${rootState}: ${rootDetail}`);
}
const [state, detail] = verdict(routingState, shapeState, verbState);
console.log(`${state}: ${detail}`);
const fix = repair(state, path, verb);
console.log(`repair: ${fix}`);
console.log(JSON.stringify({
path,
verb_sent: String(verb).toUpperCase(),
probe_status: probe.status,
documentation_url: documentationUrlOf(body),
routing_state: routingState,
path_shape_state: shapeState,
verb_state: verbState,
verb_detail: verbDetail,
get_probe_evidence: evidenceState,
permission_header_hint: hintState,
root_map_state: rootState,
probe_refusal: refusalState,
probe_refusal_detail: refusalDetail,
state,
detail,
repair: fix,
}, null, 2));
process.exitCode = ['wrong-verb', 'path-shape-wrong',
'route-absent-or-wrong-host'].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 fixtures are error bodies, taken from the two shapes GitHub really produces: a 404 naming a specific endpoint's documentation and a 404 degrading to the bare index. Those two strings are the whole diagnosis, so they get asserted directly. Then the local shape checks against a trailing slash and an unsubstituted placeholder, the template matcher against a path whose parameter smuggled in an extra segment, and the honest limit that a route with no GET cannot be proved to exist by a GET. The last test is the one that matters most for a section that promises never to write: the refusal function has to refuse, and its sentence has to contain both halves of the reason.
from github_route_or_verb import (
DOCS_INDEX, ROUTE_TABLE, SAFE_VERBS, classify_not_found, docs_url_kind,
documentation_url_of, get_probe_is_evidence, match_route,
path_shape_problem, permissions_header_hint, probe_refusal, read_cost,
repair, root_map_covers, verb_verdict, verdict,
)
# The two shapes GitHub really returns. The difference between them is the
# whole note, so they are held verbatim rather than described.
ROUTED_404 = {"message": "Not Found",
"documentation_url": "https://docs.github.com/rest/repos/repos#get-a-repository",
"status": "404"}
UNROUTED_404 = {"message": "Not Found",
"documentation_url": "https://docs.github.com/rest",
"status": "404"}
ROOT_MAP = {"current_user_url": "https://api.github.com/user",
"repository_url": "https://api.github.com/repos/{owner}/{repo}",
"emojis_url": "https://api.github.com/emojis"}
def test_the_documentation_url_is_the_discriminator():
assert docs_url_kind(documentation_url_of(ROUTED_404))[0] == "endpoint-specific"
assert docs_url_kind(documentation_url_of(UNROUTED_404))[0] == "generic"
assert docs_url_kind(DOCS_INDEX + "/")[0] == "generic"
assert docs_url_kind(None)[0] == "absent"
assert docs_url_kind("https://example.invalid/docs")[0] == "unrecognised"
def test_a_routed_404_is_somebody_elses_note():
state, detail = classify_not_found(404, ROUTED_404)
assert state == "route-matched-resource-missing"
assert "different note" in detail
assert verdict(state, "clean", "verb-not-on-this-route")[0] == "resource-not-routing"
def test_an_unrouted_404_keeps_the_investigation_here():
assert classify_not_found(404, UNROUTED_404)[0] == "nothing-routed-here"
assert classify_not_found(200, None)[0] == "route-answers-get"
assert classify_not_found(401, {})[0] == "unauthenticated"
assert classify_not_found(403, {})[0] == "refused-not-missing"
assert classify_not_found(502, {})[0] == "unexpected-status"
def test_the_trailing_slash_that_is_invisible_in_review():
state, detail = path_shape_problem("/repos/acme/payments/")
assert state == "trailing-slash"
assert "documents it as a cause of 404" in detail
assert path_shape_problem("/repos/acme/payments")[0] == "clean"
assert path_shape_problem("/repos/acme/payments?per_page=1")[0] == "clean"
def test_the_other_documented_shape_errors():
assert path_shape_problem("/repos/{owner}/payments")[0] == "placeholder-not-substituted"
assert path_shape_problem("/repos//payments")[0] == "doubled-slash"
assert path_shape_problem("/repos/acme/my payments")[0] == "unencoded-space"
assert path_shape_problem("https://api.github.com/user")[0] == "full-url-not-path"
assert path_shape_problem("repos/acme/payments")[0] == "no-leading-slash"
assert path_shape_problem("")[0] == "empty-path"
def test_the_matcher_is_segment_wise_so_a_smuggled_slash_does_not_match():
template, verbs, _ = match_route("/repos/acme/payments/collaborators/dana")
assert template == "/repos/{owner}/{repo}/collaborators/{username}"
assert set(verbs) == {"get", "put", "delete"}
# A branch name containing a slash adds a segment, so it is a different
# route, which is exactly what GitHub thinks too.
assert match_route("/repos/acme/payments/branches/release/1.0/protection")[0] is None
assert match_route("/repos/acme/payments/nothing-like-this")[0] is None
def test_the_wrong_verb_is_named_with_the_documented_one():
state, detail = verb_verdict("/repos/acme/payments/collaborators/dana", "post")
assert state == "verb-not-on-this-route"
assert "you sent POST" in detail
assert "PUT" in detail
assert verb_verdict("/repos/acme/payments/topics", "put")[0] == "verb-is-documented"
assert verb_verdict("/some/unknown/path", "put")[0] == "route-not-in-table"
def test_a_get_probe_cannot_prove_a_route_with_no_get():
state, detail = get_probe_is_evidence("/repos/acme/payments/merges")
assert state == "probe-cannot-decide"
assert "proves nothing" in detail
assert get_probe_is_evidence("/repos/acme/payments/topics")[0] == "probe-decides"
assert get_probe_is_evidence("/nope")[0] == "unknown-route"
def test_the_script_refuses_to_probe_with_a_write_and_says_both_reasons():
state, detail = probe_refusal("put")
assert state == "will-not-probe"
assert "would be a write" in detail
assert "returns no information" in detail
assert probe_refusal("get")[0] == "safe-to-send"
assert probe_refusal("head")[0] == "safe-to-send"
# And no table entry claims a changing verb is safe to send.
assert set(SAFE_VERBS) == {"get", "head"}
def test_no_route_in_the_table_is_missing_its_note():
for template, verbs, note in ROUTE_TABLE:
assert template.startswith("/"), template
assert verbs, template
assert note, template
assert all(v == v.lower() for v in verbs), template
def test_the_verdict_puts_path_shape_before_the_verb():
# A malformed path with a wrong verb is a path problem first: fixing the
# verb on a path that cannot match anything changes nothing.
state, _ = verdict("nothing-routed-here", "trailing-slash", "verb-not-on-this-route")
assert state == "path-shape-wrong"
assert verdict("nothing-routed-here", "clean", "verb-not-on-this-route")[0] == "wrong-verb"
assert verdict("route-answers-get", "clean", "verb-is-documented")[0] == (
"route-and-verb-both-fine")
assert verdict("nothing-routed-here", "clean", "verb-is-documented")[0] == (
"route-absent-or-wrong-host")
def test_the_permission_header_is_corroboration_and_says_so():
state, detail = permissions_header_hint({"X-Accepted-GitHub-Permissions": "issues=read"})
assert state == "permissions-were-evaluated"
assert "Corroboration only" in detail
assert permissions_header_hint({})[0] == "no-permission-header"
assert "too weak" in permissions_header_hint({})[1]
def test_the_root_map_is_a_hint_and_admits_its_coverage():
assert root_map_covers(ROOT_MAP, "/repos/acme/payments")[0] == "family-known"
state, detail = root_map_covers(ROOT_MAP, "/packages/npm/thing")
assert state == "family-not-in-map"
assert "hint and not a finding" in detail
assert root_map_covers({}, "/repos/a/b")[0] == "root-unread"
def test_the_repair_names_the_verb_and_does_not_send_it():
fix = repair("wrong-verb", "/repos/acme/payments/collaborators/dana", "post")
assert "send PUT or DELETE" in fix
assert "Nothing here sends it" in fix
assert "wrong GitHub installation" in repair("route-absent-or-wrong-host", "/x", "get")
assert "Do not send the verb" in repair("undetermined", "/x", "put")
def test_the_read_cost_is_known_before_anything_is_spent():
assert read_cost(False) == 1
assert read_cost(True) == 2
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
DOCS_INDEX, ROUTE_TABLE, SAFE_VERBS, classifyNotFound, docsUrlKind,
documentationUrlOf, getProbeIsEvidence, matchRoute, pathShapeProblem,
permissionsHeaderHint, probeRefusal, readCost, repair, rootMapCovers,
verbVerdict, verdict,
} from './github-route-or-verb.mjs';
const ROUTED_404 = {
message: 'Not Found',
documentation_url: 'https://docs.github.com/rest/repos/repos#get-a-repository',
status: '404',
};
const UNROUTED_404 = {
message: 'Not Found',
documentation_url: 'https://docs.github.com/rest',
status: '404',
};
const ROOT_MAP = {
current_user_url: 'https://api.github.com/user',
repository_url: 'https://api.github.com/repos/{owner}/{repo}',
emojis_url: 'https://api.github.com/emojis',
};
test('the documentation_url is the discriminator', () => {
assert.equal(docsUrlKind(documentationUrlOf(ROUTED_404))[0], 'endpoint-specific');
assert.equal(docsUrlKind(documentationUrlOf(UNROUTED_404))[0], 'generic');
assert.equal(docsUrlKind(`${DOCS_INDEX}/`)[0], 'generic');
assert.equal(docsUrlKind(null)[0], 'absent');
assert.equal(docsUrlKind('https://example.invalid/docs')[0], 'unrecognised');
});
test('a routed 404 is somebody elses note', () => {
const [state, detail] = classifyNotFound(404, ROUTED_404);
assert.equal(state, 'route-matched-resource-missing');
assert.match(detail, /different note/);
assert.equal(verdict(state, 'clean', 'verb-not-on-this-route')[0], 'resource-not-routing');
});
test('an unrouted 404 keeps the investigation here', () => {
assert.equal(classifyNotFound(404, UNROUTED_404)[0], 'nothing-routed-here');
assert.equal(classifyNotFound(200, null)[0], 'route-answers-get');
assert.equal(classifyNotFound(401, {})[0], 'unauthenticated');
assert.equal(classifyNotFound(403, {})[0], 'refused-not-missing');
assert.equal(classifyNotFound(502, {})[0], 'unexpected-status');
});
test('the trailing slash that is invisible in review', () => {
const [state, detail] = pathShapeProblem('/repos/acme/payments/');
assert.equal(state, 'trailing-slash');
assert.match(detail, /documents it as a cause of 404/);
assert.equal(pathShapeProblem('/repos/acme/payments')[0], 'clean');
assert.equal(pathShapeProblem('/repos/acme/payments?per_page=1')[0], 'clean');
});
test('the other documented shape errors', () => {
assert.equal(pathShapeProblem('/repos/{owner}/payments')[0], 'placeholder-not-substituted');
assert.equal(pathShapeProblem('/repos//payments')[0], 'doubled-slash');
assert.equal(pathShapeProblem('/repos/acme/my payments')[0], 'unencoded-space');
assert.equal(pathShapeProblem('https://api.github.com/user')[0], 'full-url-not-path');
assert.equal(pathShapeProblem('repos/acme/payments')[0], 'no-leading-slash');
assert.equal(pathShapeProblem('')[0], 'empty-path');
});
test('the matcher is segment wise so a smuggled slash does not match', () => {
const [template, verbs] = matchRoute('/repos/acme/payments/collaborators/dana');
assert.equal(template, '/repos/{owner}/{repo}/collaborators/{username}');
assert.deepEqual([...verbs].sort(), ['delete', 'get', 'put']);
assert.equal(matchRoute('/repos/acme/payments/branches/release/1.0/protection')[0], null);
assert.equal(matchRoute('/repos/acme/payments/nothing-like-this')[0], null);
});
test('the wrong verb is named with the documented one', () => {
const [state, detail] = verbVerdict('/repos/acme/payments/collaborators/dana', 'post');
assert.equal(state, 'verb-not-on-this-route');
assert.match(detail, /you sent POST/);
assert.match(detail, /PUT/);
assert.equal(verbVerdict('/repos/acme/payments/topics', 'put')[0], 'verb-is-documented');
assert.equal(verbVerdict('/some/unknown/path', 'put')[0], 'route-not-in-table');
});
test('a get probe cannot prove a route with no get', () => {
const [state, detail] = getProbeIsEvidence('/repos/acme/payments/merges');
assert.equal(state, 'probe-cannot-decide');
assert.match(detail, /proves nothing/);
assert.equal(getProbeIsEvidence('/repos/acme/payments/topics')[0], 'probe-decides');
assert.equal(getProbeIsEvidence('/nope')[0], 'unknown-route');
});
test('the script refuses to probe with a write and says both reasons', () => {
const [state, detail] = probeRefusal('put');
assert.equal(state, 'will-not-probe');
assert.match(detail, /would be a write/);
assert.match(detail, /returns no information/);
assert.equal(probeRefusal('get')[0], 'safe-to-send');
assert.equal(probeRefusal('head')[0], 'safe-to-send');
assert.deepEqual([...SAFE_VERBS].sort(), ['get', 'head']);
});
test('no route in the table is missing its note', () => {
for (const [template, verbs, note] of ROUTE_TABLE) {
assert.ok(template.startsWith('/'), template);
assert.ok(verbs.length, template);
assert.ok(note, template);
assert.ok(verbs.every((v) => v === v.toLowerCase()), template);
}
});
test('the verdict puts path shape before the verb', () => {
assert.equal(
verdict('nothing-routed-here', 'trailing-slash', 'verb-not-on-this-route')[0],
'path-shape-wrong',
);
assert.equal(verdict('nothing-routed-here', 'clean', 'verb-not-on-this-route')[0], 'wrong-verb');
assert.equal(
verdict('route-answers-get', 'clean', 'verb-is-documented')[0],
'route-and-verb-both-fine',
);
assert.equal(
verdict('nothing-routed-here', 'clean', 'verb-is-documented')[0],
'route-absent-or-wrong-host',
);
});
test('the permission header is corroboration and says so', () => {
const [state, detail] = permissionsHeaderHint({ 'X-Accepted-GitHub-Permissions': 'issues=read' });
assert.equal(state, 'permissions-were-evaluated');
assert.match(detail, /Corroboration only/);
assert.equal(permissionsHeaderHint({})[0], 'no-permission-header');
assert.match(permissionsHeaderHint({})[1], /too weak/);
});
test('the root map is a hint and admits its coverage', () => {
assert.equal(rootMapCovers(ROOT_MAP, '/repos/acme/payments')[0], 'family-known');
const [state, detail] = rootMapCovers(ROOT_MAP, '/packages/npm/thing');
assert.equal(state, 'family-not-in-map');
assert.match(detail, /hint and not a finding/);
assert.equal(rootMapCovers({}, '/repos/a/b')[0], 'root-unread');
});
test('the repair names the verb and does not send it', () => {
const fix = repair('wrong-verb', '/repos/acme/payments/collaborators/dana', 'post');
assert.match(fix, /send PUT or DELETE/);
assert.match(fix, /Nothing here sends it/);
assert.match(repair('route-absent-or-wrong-host', '/x', 'get'), /wrong GitHub installation/);
assert.match(repair('undetermined', '/x', 'put'), /Do not send the verb/);
});
test('the read cost is known before anything is spent', () => {
assert.equal(readCost(false), 1);
assert.equal(readCost(true), 2);
});
FAQ
Why does GitHub not just return 405 with an <code>Allow</code> header?
It does not, and the behaviour is documented rather than accidental: an unsupported method gets a 404 instead of a 405. Speculating about the reason is less useful than accepting the consequence, which is that the status code carries less information here than it does on most APIs and you have to get the rest from the body. The <code>documentation_url</code> field is the part that survives, and it is free.
How is this different from the note about a 404 that is really a 403?
That note sorts a 404 by <em>who is asking</em>: a dead token, a missing scope, a repository outside an App's installation. Every one of those is a request that reached a handler, and the body proves it by naming that endpoint's documentation. This note starts where that one ends, on a 404 whose body names nothing, and sorts by <em>whether anything was there to reach</em>: a malformed path, a route that only exists for another verb, or a route that does not exist on the host you are talking to.
Can I not just send the request with each verb until one works?
You can, and it is the worst available option. On this API several of the verbs in question perform a real operation on success — a PUT to the starring endpoint stars the repository, a PUT to the collaborators endpoint sends somebody an invitation — so a successful probe is a production change you did not intend. And an unsuccessful one returns the same 404 you already had, because that is the documented behaviour for an unsupported method. There is no branch of that experiment in which you learn something.
The GET probe 404s too. Does that mean the route does not exist?
Not necessarily, and this is the honest limit of the technique. Some routes have no GET representation at all: <code>/repos/{owner}/{repo}/merges</code> only accepts a creation, so a GET there returns exactly the bare 404 a nonexistent path returns. When the route the path matches has no documented GET, the script says the probe cannot decide and falls back to the verb table rather than pretending the reading was informative.
Every route 404s, not just this one. Same problem?
Almost certainly not. One route 404ing while others answer is a path or a verb; <em>everything</em> 404ing is usually a base URL pointing at a different GitHub installation from the one holding your resources, which has its own note in this section. The script's verdict says so explicitly when the path is well formed and the verb is documented and still nothing was routed, because that combination has no local explanation left.
Related field notes
- The other 404: a handler answered and would not show you
- When every route 404s because of the host
- A different documented rejection with its own status
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.
- Troubleshooting the REST API — GitHub Docs
- About the REST API — GitHub Docs
- Starring — GitHub REST API
- Collaborators — 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.