Tell the two API-key setup failures apart in verify_api.py

Ran Phase 0 against a live key and hit both of the ways a fresh Google Cloud
project can be wrong, in sequence. Google reports them as the same 403
`forbidden`, so the first version of this script printed reason='forbidden'
three times and buried the one sentence that said what to do.

The distinguishing signal is in error.details[].reason, not
error.errors[].reason:

  SERVICE_DISABLED         YouTube Data API v3 is not enabled on the project.
                           Carries an activationUrl naming the project number.
  API_KEY_SERVICE_BLOCKED  The API is enabled, but this key's API restrictions
                           exclude it.

They are fixed on different console screens, so they are now separate exception
types with separate advice, and a one-call preflight reports either before the
three real checks run and fail identically.

The ordering between them is a trap worth writing down: YouTube Data API v3 does
not appear in a key's API-restriction picker until the API is enabled on the
project, so creating the key and restricting it first yields a key that blocks
the only API it exists for. That is precisely what happened here. §4.1 step 4
now says to enable before restricting.

Nothing has yet reached YouTube's own privacy check, so whether the brother's
subscriptions are readable is still untested — every call so far failed at the
key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Flux
2026-08-12 15:45:15 +01:00
co-authored by Claude Opus 5
parent 339232c7f9
commit f3d70f1c87
2 changed files with 123 additions and 12 deletions
+95 -5
View File
@@ -38,15 +38,40 @@ ISO8601 = re.compile(
class ApiError(Exception):
def __init__(self, status, reason, body):
def __init__(self, status, reason, body, message="", activation_url=""):
super().__init__(f"HTTP {status} {reason}")
self.status = status
self.reason = reason
self.body = body
self.message = message
self.activation_url = activation_url
class ServiceDisabled(ApiError):
"""YouTube Data API v3 is not enabled on the key's project.
Signalled by `accessNotConfigured` in errors[] or `SERVICE_DISABLED` in
details[], and carries an activationUrl naming the project number.
"""
class KeyRestricted(ApiError):
"""The API is enabled, but this key's API restrictions exclude it.
Signalled by `API_KEY_SERVICE_BLOCKED` in details[] with the message
"Requests to this API youtube method ... are blocked". Distinct from
ServiceDisabled and fixed in a completely different console screen, which is
why the two are separate types.
Both were observed from one key on 2026-08-12, in this order, and the
ordering is the trap: YouTube Data API v3 does not appear in the key's API
restriction picker until the API is enabled on the project. Enable first,
restrict second, or the key is created blocking the very API it is for.
"""
def call(endpoint: str, key: str, **params) -> dict:
"""One API call. Raises ApiError with the parsed reason on 4xx/5xx."""
"""One API call. Raises ApiError (or ServiceDisabled) on 4xx/5xx."""
params["key"] = key
url = f"{API}/{endpoint}?" + urllib.parse.urlencode(params)
try:
@@ -54,13 +79,34 @@ def call(endpoint: str, key: str, **params) -> dict:
return json.load(response)
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf8", "replace")
reason = ""
reason = message = activation = ""
detail_reasons = set()
try:
errors = json.loads(raw).get("error", {}).get("errors", [])
error = json.loads(raw).get("error", {})
message = error.get("message", "")
errors = error.get("errors", [])
reason = errors[0].get("reason", "") if errors else ""
for detail in error.get("details", []):
meta = detail.get("metadata") or {}
if meta.get("activationUrl"):
activation = meta["activationUrl"]
if detail.get("reason"):
detail_reasons.add(detail["reason"])
except ValueError:
pass
raise ApiError(exc.code, reason, raw[:400]) from exc
# `forbidden` is used for both of these, so the details[] reason is what
# actually distinguishes them. Check the specific ones before it.
if "API_KEY_SERVICE_BLOCKED" in detail_reasons:
cls = KeyRestricted
elif ("SERVICE_DISABLED" in detail_reasons
or reason == "accessNotConfigured"
or "has not been used in project" in message):
cls = ServiceDisabled
else:
cls = ApiError
raise cls(exc.code, reason or next(iter(detail_reasons), ""),
raw[:400], message, activation) from exc
def iso8601_seconds(text: str) -> int | None:
@@ -198,6 +244,49 @@ def check_durations(key: str, channel_id: str) -> None:
f"sample: " + ", ".join(f"{v}={s}s" for v, s in parsed[:5]))
def preflight(key: str) -> None:
"""One cheap call to separate "project not set up" from "assumption wrong".
Without this, a disabled API fails all three checks with three different
messages and none of them says what to do about it.
"""
try:
call("videos", key, part="id", id="dQw4w9WgXcQ")
except ServiceDisabled as exc:
print("\nSTOP: the key is valid, but YouTube Data API v3 is not enabled "
"on its project.")
print(f"\n {exc.message}\n")
if exc.activation_url:
print(" Enable it here, then wait ~2 minutes and re-run:")
print(f" {exc.activation_url}\n")
print(" Creating an API key and enabling the API are separate steps in "
"the console;\n only the first one has been done.")
raise SystemExit(2)
except KeyRestricted as exc:
project = ""
match = re.search(r"projects?/(\d+)", exc.body) or \
re.search(r"project=(\d+)", exc.body)
if match:
project = f"?project={match.group(1)}"
print("\nSTOP: the API is enabled, but this key's API restrictions block "
"it (API_KEY_SERVICE_BLOCKED).")
print(f"\n {exc.message}\n")
print(" Fix: Credentials -> click the key -> API restrictions -> either")
print(' "Don\'t restrict key", or tick "YouTube Data API v3" in the list.')
print(f" https://console.cloud.google.com/apis/credentials{project}\n")
print(" Note the ordering trap: YouTube Data API v3 is absent from that")
print(" picker until the API is enabled on the project, so a key created")
print(" and restricted first ends up blocking the API it was made for.")
raise SystemExit(2)
except ApiError as exc:
if exc.status in (400, 403) and exc.reason in ("badRequest", "keyInvalid",
"API_KEY_INVALID"):
print(f"\nSTOP: the key was rejected -- {exc.reason}: {exc.message}")
raise SystemExit(2)
# Anything else is worth letting the real checks characterise.
print(f"\n (preflight warning: {exc} reason={exc.reason!r})")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--key", required=True, help="YouTube Data API v3 key")
@@ -208,6 +297,7 @@ def main() -> int:
args = parser.parse_args()
print(f"Phase 0 verification against the live API (~5 quota units of 10,000/day)")
preflight(args.key)
check_subscriptions(args.key, args.brother)
check_uploads_playlist(args.key, args.sample)
check_durations(args.key, args.sample)