Files
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
Test / Code Quality (push) Waiting to run
Test / Test (macos-latest, Python 3.10) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.11) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.12) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.13) (push) Blocked by required conditions
Test / Test (macos-latest, Python 3.14) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.10) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.11) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.12) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.13) (push) Blocked by required conditions
Test / Test (ubuntu-latest, Python 3.14) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.10) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.11) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.12) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.13) (push) Blocked by required conditions
Test / Test (windows-latest, Python 3.14) (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

1582 lines
58 KiB
Python

#!/usr/bin/env python3
"""RPC Health Check - Verify NotebookLM RPC method IDs are still valid.
This script makes minimal API calls to exercise RPC methods and verify
that the method IDs in rpc/types.py still match what the API returns.
Exit codes:
0 - All RPC methods OK (or only transient errors: rate-limits / ReadTimeouts)
1 - One or more RPC methods have mismatched IDs
2 - Authentication or infrastructure failure (not an RPC problem)
3 - One or more RPC methods returned a non-transient ERROR
(timeouts, parse failures, unexpected HTTP errors)
4 - Studio-customization cohort flip: the ``sqTeoe`` tripwire
(GetArtifactCustomizationChoices) returned non-null, meaning our
account migrated to the new customization surface and the VideoStyle /
format codes must be re-captured. GATED-null is the expected steady
state and is NOT a failure.
Priority order when multiple statuses are present:
MISMATCH (1) > AUTH (2) > non-transient ERROR (3) > cohort MIGRATED (4) > OK (0)
Transient errors that still exit 0 are limited to rate-limit signals
(HTTP 429, gRPC ``RESOURCE_EXHAUSTED``, and the decoder's user-displayable
``API rate limit`` / quota messages raised as ``RateLimitError``) plus
``httpx.ReadTimeout`` against Google's RPC endpoints — those are almost
always server-side slowness, not an RPC contract change, and they
consistently pass on retry (see #1004). Everything else (parse failures,
unexpected HTTP status codes, schema mismatches) is still treated as a
real failure so the nightly canary can flag silent breakage.
Environment variables:
NOTEBOOKLM_AUTH_JSON - Playwright storage state JSON (required)
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID - Notebook ID for read operations
NOTEBOOKLM_GENERATION_NOTEBOOK_ID - Notebook ID for write operations
NOTEBOOKLM_RPC_DELAY - Delay between RPC calls in seconds (default: 1.0)
Usage:
python scripts/check_rpc_health.py # Quick mode (skip destructive)
python scripts/check_rpc_health.py --full # Full mode (create temp notebook)
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from collections import Counter
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from uuid import uuid4
import httpx
from notebooklm._artifact.payloads import build_retry_artifact_params
from notebooklm._chat.wire import (
build_streaming_chat_request,
parse_streaming_chat_response,
)
from notebooklm._env import get_default_language
from notebooklm._logging import scrub_secrets
from notebooklm._notebooks import build_create_notebook_params
from notebooklm.auth import (
AuthTokens,
fetch_tokens,
get_account_email_for_storage,
get_authuser_for_storage,
load_auth_from_storage,
)
from notebooklm.exceptions import ChatError, ChatResponseParseError, DecodingError
from notebooklm.paths import get_storage_path
from notebooklm.rpc import (
RPCError,
RPCMethod,
build_request_body,
encode_rpc_request,
get_batchexecute_url,
)
from notebooklm.rpc.decoder import (
collect_rpc_ids,
decode_response,
parse_chunked_response,
strip_anti_xssi,
)
from notebooklm.rpc.types import _QUERY_ENDPOINT_PATH
class CheckStatus(str, Enum):
"""Result status for an RPC check."""
OK = "OK"
MISMATCH = "MISMATCH"
ERROR = "ERROR"
SKIPPED = "SKIPPED"
class CohortStatus(str, Enum):
"""Result of the ``sqTeoe`` studio-customization cohort tripwire.
``GetArtifactCustomizationChoices`` (rpc id ``sqTeoe``) is gated off for our
consumer cohort and returns ``null`` unconditionally today (``GATED`` — the
expected steady state, NOT a failure). The day it starts returning a
non-null payload our account has been migrated to the new studio-customization
surface (``MIGRATED`` — a loud signal: the VideoStyle/format codes must be
re-captured). ``UNKNOWN`` covers a transient/transport failure on the probe
itself (no cohort conclusion drawn — never an alarm).
"""
GATED = "GATED"
MIGRATED = "MIGRATED"
UNKNOWN = "UNKNOWN"
# Studio-customization cohort tripwire RPC. NOT in ``RPCMethod`` (it is gated off
# for our consumer cohort, so there is no typed/public path), called by raw id.
_CUSTOMIZATION_CHOICES_RPC_ID = "sqTeoe"
@dataclass
class CheckResult:
"""Result of checking a single RPC method."""
method: RPCMethod
status: CheckStatus
expected_id: str
found_ids: list[str]
error: str | None = None
# Delay between RPC calls to avoid rate limiting (seconds)
# Can be overridden via NOTEBOOKLM_RPC_DELAY env var
CALL_DELAY = float(os.environ.get("NOTEBOOKLM_RPC_DELAY", "1.0"))
# Status display icons
STATUS_ICONS = {
CheckStatus.OK: "OK",
CheckStatus.MISMATCH: "MISMATCH",
CheckStatus.ERROR: "ERROR",
CheckStatus.SKIPPED: "SKIP",
}
# Methods that are duplicates (same ID, different name)
# Currently empty - no duplicate method IDs in use
DUPLICATE_METHODS: set[RPCMethod] = set()
# Methods that require real resource IDs (fail with placeholders).
# These return HTTP 400 with placeholder IDs but would work with real IDs.
# Currently empty but kept for future additions.
PLACEHOLDER_FAIL_METHODS: set[RPCMethod] = set()
# Methods that can only be tested in full mode (with temp notebook)
# These are destructive or create resources
FULL_MODE_ONLY_METHODS = {
# Create operations
RPCMethod.CREATE_NOTEBOOK,
RPCMethod.ADD_SOURCE,
RPCMethod.ADD_SOURCE_FILE, # Registers file source intent (no upload needed)
RPCMethod.CREATE_NOTE,
RPCMethod.CREATE_ARTIFACT, # Main RPC for all artifacts - test with flashcards (fast)
RPCMethod.START_FAST_RESEARCH, # Starts research (verify RPC ID, don't wait)
# Delete operations (tested after creates)
RPCMethod.DELETE_NOTE,
RPCMethod.DELETE_SOURCE,
RPCMethod.DELETE_ARTIFACT, # Main RPC for artifact deletion
RPCMethod.DELETE_NOTEBOOK,
RPCMethod.DELETE_CONVERSATION, # Destructive; needs a real conversation to delete
}
# Methods always skipped (even in full mode)
ALWAYS_SKIP_METHODS = {
# Takes too long
RPCMethod.START_DEEP_RESEARCH,
}
@dataclass
class TempResources:
"""Tracks temporarily created resources for cleanup."""
notebook_id: str | None = None
source_id: str | None = None
note_id: str | None = None
artifact_id: str | None = None # Flashcard artifact for DELETE_ARTIFACT test
def extract_id_recursive(data: Any) -> str | None:
"""Recursively extract the first string/int ID from nested response data.
Drills down through nested lists until it finds a string or int.
This handles various response formats like:
- [[['id', ...]]] -> 'id'
- [['id', ...]] -> 'id'
- ['id', ...] -> 'id'
Args:
data: Response data (typically a nested list)
Returns:
The extracted string ID or None if not found
"""
if data is None:
return None
if isinstance(data, str | int):
return str(data)
if isinstance(data, list) and len(data) > 0:
return extract_id_recursive(data[0])
return None
def extract_id(data: Any, *indices: int) -> str | None:
"""Safely extract an ID from nested response data.
Args:
data: Response data (typically a nested list)
indices: Index path to traverse (e.g., 0 for data[0], or 0, 0 for data[0][0])
Returns:
The extracted string ID or None if not found
"""
try:
result = data
for idx in indices:
result = result[idx]
# API responses have varying nesting depths (e.g., [[['id']]], [['id']], ['id']).
# Recursively drill down to find the actual string/int ID.
return extract_id_recursive(result)
except (IndexError, TypeError):
return None
def load_auth() -> dict[str, str]:
"""Load auth from environment or storage file.
Uses the library's load_auth_from_storage() which handles:
- NOTEBOOKLM_AUTH_JSON env var (for CI)
- ~/.notebooklm/storage_state.json file (for local dev)
- Proper cookie domain filtering
"""
try:
cookies = load_auth_from_storage()
except FileNotFoundError:
print(
"ERROR: No authentication found.\n"
"Set NOTEBOOKLM_AUTH_JSON env var or run 'notebooklm login'",
file=sys.stderr,
)
sys.exit(2)
except ValueError as e:
print(f"ERROR: Invalid authentication: {e}", file=sys.stderr)
sys.exit(2)
return cookies
def resolve_storage_path() -> Path | None:
"""Return the file-based auth path, or None when NOTEBOOKLM_AUTH_JSON is used."""
if "NOTEBOOKLM_AUTH_JSON" in os.environ:
return None
return get_storage_path()
async def make_rpc_request(
client: httpx.AsyncClient,
auth: AuthTokens,
method: RPCMethod,
params: list[Any],
source_path: str = "/",
) -> tuple[str | None, str | None]:
"""Make an RPC request and return raw response text.
Args:
client: HTTP client
auth: Authentication tokens
method: RPC method to call
params: Method parameters
source_path: Source path for the request (default: "/")
Returns:
Tuple of (response text or None, error message or None)
"""
query_params = {
"rpcids": method.value,
"source-path": source_path,
"f.sid": auth.session_id,
"hl": get_default_language(),
"rt": "c",
}
if auth.account_email or auth.authuser:
query_params["authuser"] = auth.account_route
url = f"{get_batchexecute_url()}?{urlencode(query_params)}"
rpc_request = encode_rpc_request(method, params)
body = build_request_body(rpc_request, auth.csrf_token)
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": auth.cookie_header,
}
try:
response = await client.post(url, content=body, headers=headers)
response.raise_for_status()
return response.text, None
except httpx.HTTPStatusError as e:
return None, f"HTTP {e.response.status_code}"
except httpx.RequestError as e:
# ``httpx.ReadTimeout`` can stringify to ``""``; fall back to the
# class name so callers don't mislabel it as an empty response (#864).
return None, str(e) or type(e).__name__
async def make_rpc_call(
client: httpx.AsyncClient,
auth: AuthTokens,
method: RPCMethod,
params: list[Any],
source_path: str = "/",
) -> tuple[list[str], str | None]:
"""Make an RPC call and return found IDs.
Args:
client: HTTP client
auth: Authentication tokens
method: RPC method to call
params: Method parameters
source_path: Source path for the request (default: "/")
Returns:
Tuple of (list of RPC IDs found in response, error message or None)
"""
response_text, error = await make_rpc_request(client, auth, method, params, source_path)
if error is not None:
return [], error
if response_text is None:
return [], "Empty response from server"
try:
cleaned = strip_anti_xssi(response_text)
chunks = parse_chunked_response(cleaned)
found_ids = collect_rpc_ids(chunks)
return found_ids, None
except (json.JSONDecodeError, ValueError, IndexError, TypeError) as e:
return [], f"Parse error: {e}"
async def test_rpc_method(
client: httpx.AsyncClient,
auth: AuthTokens,
method: RPCMethod,
params: list[Any],
source_path: str = "/",
) -> CheckResult:
"""Test an RPC method and return a CheckResult.
Args:
client: HTTP client
auth: Authentication tokens
method: RPC method to call
params: Method parameters
source_path: Source path for the request (default: "/")
Returns:
CheckResult with test status
Makes the RPC call and checks if the expected method ID appears in the response.
"""
expected_id = method.value
found_ids, error = await make_rpc_call(client, auth, method, params, source_path)
if expected_id in found_ids:
return CheckResult(
method=method,
status=CheckStatus.OK,
expected_id=expected_id,
found_ids=found_ids,
)
return CheckResult(
method=method,
status=CheckStatus.ERROR,
expected_id=expected_id,
found_ids=found_ids,
error=error if error is not None else "RPC ID not found in response",
)
async def test_rpc_method_with_data(
client: httpx.AsyncClient,
auth: AuthTokens,
method: RPCMethod,
params: list[Any],
source_path: str = "/",
) -> tuple[CheckResult, Any]:
"""Test an RPC method and return both CheckResult and response data.
Args:
client: HTTP client
auth: Authentication tokens
method: RPC method to call
params: Method parameters
source_path: Source path for the request (default: "/")
Returns:
Tuple of (CheckResult, decoded response data)
Use this when you need the response data (e.g., to extract created resource IDs).
"""
expected_id = method.value
response_text, error = await make_rpc_request(client, auth, method, params, source_path)
if error is not None:
return CheckResult(
method=method,
status=CheckStatus.ERROR,
expected_id=expected_id,
found_ids=[],
error=error,
), None
if response_text is None:
return CheckResult(
method=method,
status=CheckStatus.ERROR,
expected_id=expected_id,
found_ids=[],
error="Empty response from server",
), None
try:
cleaned = strip_anti_xssi(response_text)
chunks = parse_chunked_response(cleaned)
found_ids = collect_rpc_ids(chunks)
data = decode_response(response_text, method.value)
except (json.JSONDecodeError, ValueError, IndexError, TypeError, RPCError) as e:
return CheckResult(
method=method,
status=CheckStatus.ERROR,
expected_id=expected_id,
found_ids=[],
error=f"Parse error: {e}",
), None
status = CheckStatus.OK if expected_id in found_ids else CheckStatus.ERROR
error_msg = None if status == CheckStatus.OK else "RPC ID not found in response"
return CheckResult(
method=method,
status=status,
expected_id=expected_id,
found_ids=found_ids,
error=error_msg,
), data
def format_check_output(result: CheckResult, suffix: str | None = None) -> str:
"""Format a CheckResult for console output."""
status_icon = STATUS_ICONS[result.status]
line = f"{status_icon:8} {result.method.name}"
if suffix:
line += f" - {suffix}"
elif result.error and result.status != CheckStatus.OK:
line += f" - {result.error}"
return line
def format_check_with_success(result: CheckResult, success_msg: str) -> str:
"""Format a CheckResult, showing success_msg only when OK."""
suffix = success_msg if result.status == CheckStatus.OK else None
return format_check_output(result, suffix)
def get_test_params(method: RPCMethod, notebook_id: str | None) -> list[Any] | None:
"""Get test parameters for an RPC method.
Returns None if method cannot be tested with simple params.
"""
# Methods that work without a notebook
if method == RPCMethod.LIST_NOTEBOOKS:
return []
# Global settings (no notebook required)
if method == RPCMethod.GET_USER_SETTINGS:
# Params to read current settings
return [None, [1, None, None, None, None, None, None, None, None, None, [1]]]
if method == RPCMethod.SET_USER_SETTINGS:
# Params structure: [[[null,[[null,null,null,null,["language_code"]]]]]]
# Use "en" as safe language code
return [[[None, [[None, None, None, None, ["en"]]]]]]
# Methods that require a notebook ID
if not notebook_id:
return None
# Methods that take [notebook_id] as the only param
if method in (
RPCMethod.GET_NOTEBOOK,
RPCMethod.GET_SOURCE_GUIDE,
RPCMethod.GET_SHARE_STATUS,
RPCMethod.REMOVE_RECENTLY_VIEWED,
):
return [notebook_id]
# GET_SUGGESTED_REPORTS has special params: [[2], notebook_id].
# Suggestions only exist once a notebook has indexed sources, so the
# freshly-created temp notebook used in --full mode returns an empty
# body and trips the empty-response guard. When a stable read-only
# notebook is available, route this method there instead so the
# canary keeps drift-checking the RPC ID.
if method == RPCMethod.GET_SUGGESTED_REPORTS:
stable_id = (
os.environ.get("NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID")
or os.environ.get("NOTEBOOKLM_GENERATION_NOTEBOOK_ID")
or notebook_id
)
return [[2], stable_id]
# Methods that take [[notebook_id]] as the only param.
if method == RPCMethod.GET_NOTES_AND_MIND_MAPS:
return [[notebook_id]]
# GET_LAST_CONVERSATION_ID: returns most recent conversation ID
if method == RPCMethod.GET_LAST_CONVERSATION_ID:
return [[], None, notebook_id, 1]
# GET_CONVERSATION_TURNS: placeholder conv ID - API echoes RPC ID even in error response
if method == RPCMethod.GET_CONVERSATION_TURNS:
return [[], None, None, "placeholder_conv_id", 2]
# SUGGEST_PROMPTS (GeneratePromptSuggestions): like GET_SUGGESTED_REPORTS,
# suggestions only exist once a notebook has indexed sources, so route to a
# stable read-only notebook when one is configured. The required mode enum
# (1..10) is set to the default 4; an empty source-id list scopes to all of
# the notebook's sources server-side. The canary only verifies the RPC ID
# echoes back, so empty source ids are sufficient.
if method == RPCMethod.SUGGEST_PROMPTS:
stable_id = (
os.environ.get("NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID")
or os.environ.get("NOTEBOOKLM_GENERATION_NOTEBOOK_ID")
or notebook_id
)
return [
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
stable_id,
[],
4,
None,
None,
]
# LIST_ARTIFACTS has special params
if method == RPCMethod.LIST_ARTIFACTS:
return [[2], notebook_id, 'NOT artifact.status = "ARTIFACT_STATUS_SUGGESTED"']
# LIST_LABELS: list a notebook's source labels (read-only; OPTS wrapper + nb id)
if method == RPCMethod.LIST_LABELS:
return [
[2, None, None, [1, None, None, None, None, None, None, None, None, None, [1]]],
notebook_id,
]
# Notebook operations (read-only - rename to same name is a no-op)
if method == RPCMethod.RENAME_NOTEBOOK:
return [notebook_id, "RPC Health Check Test", None, None, None]
# Source operations (read-only - use placeholder IDs)
if method == RPCMethod.GET_SOURCE:
return [[notebook_id], ["placeholder_source_id"]]
if method in (RPCMethod.REFRESH_SOURCE, RPCMethod.CHECK_SOURCE_FRESHNESS):
return [[notebook_id], [["placeholder"]]]
if method == RPCMethod.UPDATE_SOURCE:
return [[notebook_id], "placeholder", "New Title"]
# Summary operations (read-only)
if method == RPCMethod.SUMMARIZE:
return [[notebook_id], [], "Summarize the content"]
# Artifact operations (read-only - use placeholder IDs)
if method == RPCMethod.GET_INTERACTIVE_HTML:
return [[notebook_id], "placeholder"]
if method == RPCMethod.RENAME_ARTIFACT:
return [[notebook_id], "placeholder", "New Name"]
if method == RPCMethod.EXPORT_ARTIFACT:
return [[notebook_id], "placeholder", 1]
if method == RPCMethod.REVISE_SLIDE:
# Params: [[2], artifact_id, [[[slide_index, prompt]]]]
# Will fail with placeholder artifact_id but still echoes method ID in error response
return [[2], "placeholder_artifact_id", [[[0, "RPC health check test"]]]]
if method == RPCMethod.RETRY_ARTIFACT:
# Params: [retry_options, artifact_id]. The placeholder artifact_id
# matches no real artifact, so this is a safe liveness probe (no
# actual retry is kicked off) that still echoes the method ID in the
# error response — same posture as REVISE_SLIDE/RENAME_ARTIFACT above.
return build_retry_artifact_params("placeholder_artifact_id")
# Research operations (read-only - poll/import only)
if method == RPCMethod.POLL_RESEARCH:
return [[notebook_id], "placeholder_task_id"]
if method == RPCMethod.IMPORT_RESEARCH:
return [[notebook_id], "placeholder_research_id"]
# Note operations (read-only - update only)
if method == RPCMethod.UPDATE_NOTE:
return [[notebook_id], "placeholder", "Updated", "Updated content"]
# Mind map operation (read-only)
if method == RPCMethod.GENERATE_MIND_MAP:
return [[notebook_id], [], 5] # Mind map type
# Sharing operations (read-only checks)
if method == RPCMethod.SHARE_ARTIFACT:
return [[notebook_id], "placeholder", True]
if method == RPCMethod.SHARE_NOTEBOOK:
return [notebook_id, 1] # Restricted
return None
async def check_method(
client: httpx.AsyncClient,
auth: AuthTokens,
method: RPCMethod,
notebook_id: str | None,
full_mode: bool = False,
) -> CheckResult:
"""Check a single RPC method."""
expected_id = method.value
# Always skip certain methods
if method in ALWAYS_SKIP_METHODS:
return CheckResult(
method=method,
status=CheckStatus.SKIPPED,
expected_id=expected_id,
found_ids=[],
error="Method always skipped (complex setup or quota)",
)
if method in DUPLICATE_METHODS:
return CheckResult(
method=method,
status=CheckStatus.SKIPPED,
expected_id=expected_id,
found_ids=[],
error="Duplicate method (same ID as another)",
)
if method in PLACEHOLDER_FAIL_METHODS:
return CheckResult(
method=method,
status=CheckStatus.SKIPPED,
expected_id=expected_id,
found_ids=[],
error="Requires real resource IDs (placeholder fails)",
)
# Skip full-mode-only methods - they're handled in setup/cleanup phases
if method in FULL_MODE_ONLY_METHODS:
skip_reason = (
"Tested in setup/cleanup phases"
if full_mode
else "Requires --full mode (creates/deletes resources)"
)
return CheckResult(
method=method,
status=CheckStatus.SKIPPED,
expected_id=expected_id,
found_ids=[],
error=skip_reason,
)
# Get test params
params = get_test_params(method, notebook_id)
if params is None:
return CheckResult(
method=method,
status=CheckStatus.SKIPPED,
expected_id=expected_id,
found_ids=[],
error="No test parameters available",
)
# Make the call
found_ids, error = await make_rpc_call(client, auth, method, params)
if error is not None:
# Check if error response still contains our expected ID
if expected_id in found_ids:
return CheckResult(
method=method,
status=CheckStatus.OK,
expected_id=expected_id,
found_ids=found_ids,
error=f"Call failed but ID found: {error}",
)
return CheckResult(
method=method,
status=CheckStatus.ERROR,
expected_id=expected_id,
found_ids=found_ids,
error=error,
)
# Check if expected ID is in response
status = CheckStatus.OK if expected_id in found_ids else CheckStatus.MISMATCH
error_msg = None if status == CheckStatus.OK else f"Expected '{expected_id}' not in response"
return CheckResult(
method=method,
status=status,
expected_id=expected_id,
found_ids=found_ids,
error=error_msg,
)
@dataclass
class _ChatQueryProbe:
"""Method-like sentinel for the ``GenerateFreeFormStreamed`` chat probe.
The streamed-chat orchestration endpoint is NOT a batchexecute RPC ID — it
is a ``PATH_NOT_METHOD`` ``v1`` URL segment (see ``_QUERY_ENDPOINT_PATH`` in
``rpc/types.py``), so it has no :class:`RPCMethod` enum member and cannot be
probed by ``make_rpc_call``/``check_method`` like the obfuscated method IDs.
This ducktype gives the probe's :class:`CheckResult` the ``.name`` /
``.value`` attributes that ``print_summary`` / ``format_check_output`` /
``partition_errors`` read, so the chat result flows through the existing
summary + exit-code machinery unchanged (an ``ERROR`` here is classified
and reported as drift exactly like a method-ID probe).
"""
name: str = "GENERATE_FREE_FORM_STREAMED"
value: str = _QUERY_ENDPOINT_PATH
# Singleton sentinel reused for every chat-probe CheckResult.
CHAT_QUERY_PROBE = _ChatQueryProbe()
async def check_chat_query(
client: httpx.AsyncClient,
auth: AuthTokens,
notebook_id: str | None,
) -> CheckResult:
"""Probe the streamed-chat ``GenerateFreeFormStreamed`` orchestration RPC.
This is the chat surface's drift canary. Unlike the batchexecute method-ID
probes, the chat endpoint is a hardcoded ``v1`` path with no obfuscated RPC
ID to echo back, so liveness is asserted on the *wire shape* instead: issue
one minimal request and require an HTTP 200 plus a recognizable stream frame
(a ``wrb.fr`` envelope or a server ``"er"`` frame).
Classification mirrors the method-ID probes:
* Parseable stream (an answer, or a server-side ``ChatError`` frame the
parser recognizes — including a rate-limit / rejected frame) -> ``OK``:
the wire contract is intact, the server merely declined this request.
* Zero parseable chunks (:class:`ChatResponseParseError`) -> ``ERROR``: the
response body was empty or the wire format drifted. This is the schema
drift the canary exists to catch.
* Non-200 / transport failure -> ``ERROR`` (rate-limit / ReadTimeout text is
still classified as transient downstream by ``is_transient_error``).
Skipped when no notebook ID is configured (the request needs one for
server-side conversation persistence).
"""
if not notebook_id:
return CheckResult(
method=CHAT_QUERY_PROBE, # type: ignore[arg-type]
status=CheckStatus.SKIPPED,
expected_id=CHAT_QUERY_PROBE.value,
found_ids=[],
error="No notebook ID provided (chat probe needs one)",
)
url, body, extra_headers = build_streaming_chat_request(
snapshot=auth,
notebook_id=notebook_id,
question="RPC health check ping.",
source_ids=[],
conversation_history=None,
conversation_id=None,
reqid=int(uuid4().int % 1_000_000),
)
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": auth.cookie_header,
**extra_headers,
}
try:
response = await client.post(url, content=body, headers=headers)
response.raise_for_status()
except httpx.HTTPStatusError as e:
return CheckResult(
method=CHAT_QUERY_PROBE, # type: ignore[arg-type]
status=CheckStatus.ERROR,
expected_id=CHAT_QUERY_PROBE.value,
found_ids=[],
error=f"HTTP {e.response.status_code}",
)
except httpx.RequestError as e:
# ``httpx.ReadTimeout`` can stringify to ``""``; fall back to the class
# name so the downstream transient classifier (and the operator) sees a
# usable signal — same posture as ``make_rpc_request`` (#864).
return CheckResult(
method=CHAT_QUERY_PROBE, # type: ignore[arg-type]
status=CheckStatus.ERROR,
expected_id=CHAT_QUERY_PROBE.value,
found_ids=[],
error=str(e) or type(e).__name__,
)
try:
parse_streaming_chat_response(response.text)
except (ChatResponseParseError, DecodingError, json.JSONDecodeError) as e:
# Zero parseable chunks (empty/drifted body), a structurally malformed
# JSON body (``json.JSONDecodeError`` — a ``ValueError`` subclass we
# catch specifically so a bare ``ValueError`` from a real bug still
# propagates), OR strict positional drift raising DecodingError /
# UnknownRPCMethodError from the wire decoder: all are the schema-drift
# signal the canary exists to surface. Catch them here so a drifted
# response returns a clean ERROR result rather than crashing the canary.
return CheckResult(
method=CHAT_QUERY_PROBE, # type: ignore[arg-type]
status=CheckStatus.ERROR,
expected_id=CHAT_QUERY_PROBE.value,
found_ids=[],
error=f"Parse error: {e}",
)
except ChatError as e:
# A recognized server-side ``"er"`` / rate-limit frame: the wire shape
# is INTACT (the parser identified the frame), the server simply
# declined this request. Treat as OK — the contract is healthy. The
# message is preserved for the operator (and so a rate-limit frame
# stays visible), but it does not fail the canary.
return CheckResult(
method=CHAT_QUERY_PROBE, # type: ignore[arg-type]
status=CheckStatus.OK,
expected_id=CHAT_QUERY_PROBE.value,
found_ids=[CHAT_QUERY_PROBE.value],
error=f"Server declined (recognized frame): {e}",
)
return CheckResult(
method=CHAT_QUERY_PROBE, # type: ignore[arg-type]
status=CheckStatus.OK,
expected_id=CHAT_QUERY_PROBE.value,
found_ids=[CHAT_QUERY_PROBE.value],
)
def build_customization_choices_params(notebook_id: str) -> list[Any]:
"""Build ``sqTeoe`` (GetArtifactCustomizationChoices) params for ``notebook_id``.
Reverse-engineered (live-verified) request shape: ``[nbctx, None, artifact_type]``
where ``nbctx`` is the standard notebook-context options wrapper and
``artifact_type`` is ``3`` (video) — the surface whose VideoStyle/format codes
we most want an early migration signal for. Extracted as a helper so the wire
shape is testable without a live call.
"""
nbctx = [
2,
None,
None,
[1, None, None, None, None, None, None, None, None, None, [1]],
[notebook_id],
]
artifact_type = 3 # video
return [nbctx, None, artifact_type]
async def make_raw_rpc_request(
client: httpx.AsyncClient,
auth: AuthTokens,
rpc_id: str,
params: list[Any],
source_path: str = "/",
) -> tuple[str | None, str | None]:
"""Issue a batchexecute call by *raw* rpc id (for methods not in ``RPCMethod``).
The cohort tripwire probes ``sqTeoe``, which has no ``RPCMethod`` member (it
is gated for our cohort), so it cannot go through ``make_rpc_request``. This
mirrors that function's wire assembly but threads the id straight into both
the ``rpcids=`` query param and ``encode_rpc_request``'s ``rpc_id_override``
(the two MUST stay in sync). It calls the executor directly — there is no
idempotency registry in this script, so no retries to disable.
"""
query_params = {
"rpcids": rpc_id,
"source-path": source_path,
"f.sid": auth.session_id,
"hl": get_default_language(),
"rt": "c",
}
if auth.account_email or auth.authuser:
query_params["authuser"] = auth.account_route
url = f"{get_batchexecute_url()}?{urlencode(query_params)}"
# Any RPCMethod member works as the first arg — ``rpc_id_override`` is what
# actually lands in the body, kept identical to the ``rpcids=`` query param.
rpc_request = encode_rpc_request(RPCMethod.LIST_NOTEBOOKS, params, rpc_id_override=rpc_id)
body = build_request_body(rpc_request, auth.csrf_token)
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": auth.cookie_header,
}
try:
response = await client.post(url, content=body, headers=headers)
response.raise_for_status()
return response.text, None
except httpx.HTTPStatusError as e:
return None, f"HTTP {e.response.status_code}"
except httpx.RequestError as e:
# httpx.RequestError subclasses can stringify the failed request URL,
# which carries f.sid; scrub before it reaches a live print site.
return None, scrub_secrets(str(e) or type(e).__name__)
async def check_customization_cohort(
client: httpx.AsyncClient,
auth: AuthTokens,
notebook_id: str | None,
) -> tuple[CohortStatus, str]:
"""Probe ``sqTeoe`` to detect a studio-customization cohort flip.
``GetArtifactCustomizationChoices`` returns ``null`` for our gated consumer
cohort. We decode with ``allow_null=True`` so a genuine null payload is
``GATED`` (the steady state, not a failure) — but the *absent-id* drift guard
inside ``decode_response`` still fires if the id stopped being echoed (that
surfaces as a parse error here and is reported as ``UNKNOWN``, not silently
swallowed). A non-null payload means our account was migrated to the new
customization surface (``MIGRATED`` — re-capture VideoStyle codes).
Returns ``(status, detail)``. ``UNKNOWN`` is drawn for any transport/parse
failure so the tripwire never alarms on a transient flake; only a clean,
decoded non-null result is ``MIGRATED``.
"""
if not notebook_id:
return CohortStatus.UNKNOWN, "No notebook ID provided (cohort tripwire needs one)"
response_text, error = await make_raw_rpc_request(
client,
auth,
_CUSTOMIZATION_CHOICES_RPC_ID,
build_customization_choices_params(notebook_id),
source_path=f"/notebook/{notebook_id}",
)
if error is not None:
return CohortStatus.UNKNOWN, error
if response_text is None:
return CohortStatus.UNKNOWN, "Empty response from server"
# RPCError (incl. UnknownRPCMethodError from the absent-id drift guard) must
# propagate so the tripwire LOUDLY surfaces an id rotation / protocol break
# rather than degrading it to a quiet UNKNOWN. Bare ValueError likewise
# propagates — it signals a code bug (e.g. an invalid int() conversion), not
# API shape drift. Only a genuinely unreadable/malformed JSON root is caught
# and reported as UNKNOWN here.
try:
data = decode_response(response_text, _CUSTOMIZATION_CHOICES_RPC_ID, allow_null=True)
except (json.JSONDecodeError, TypeError) as e:
return CohortStatus.UNKNOWN, f"Parse error: {scrub_secrets(e)}"
if data is None:
return CohortStatus.GATED, "null (gated — expected steady state)"
return CohortStatus.MIGRATED, "non-null payload (cohort migrated)"
async def setup_temp_resources(
client: httpx.AsyncClient,
auth: AuthTokens,
results: list[CheckResult],
) -> TempResources:
"""Create temporary resources for full mode testing.
Tests CREATE_NOTEBOOK, ADD_SOURCE, ADD_SOURCE_FILE, START_FAST_RESEARCH,
CREATE_NOTE, and CREATE_ARTIFACT RPC methods.
Gives artifact generation a short grace period before testing DELETE_ARTIFACT
in cleanup.
"""
temp = TempResources()
# Test CREATE_NOTEBOOK - extract notebook_id from response[0]
title = f"RPC-Health-Check-{uuid4().hex[:8]}"
result, data = await test_rpc_method_with_data(
client,
auth,
RPCMethod.CREATE_NOTEBOOK,
build_create_notebook_params(title),
)
results.append(result)
print(format_check_with_success(result, "temp notebook created"))
if result.status != CheckStatus.OK:
return temp
# Notebook ID is at position [2] in CREATE_NOTEBOOK response
# Response format: [title, None, notebook_id, ...]
temp.notebook_id = extract_id(data, 2)
if not temp.notebook_id:
print(
"WARNING: Notebook created but ID not found in response. May need manual cleanup.",
file=sys.stderr,
)
return temp
# Test ADD_SOURCE - extract source_id from response[0][0]
# Params format: [[[None, [title, content], None*6]], notebook_id, [2], None, None]
await asyncio.sleep(CALL_DELAY)
result, data = await test_rpc_method_with_data(
client,
auth,
RPCMethod.ADD_SOURCE,
[
[
[
None,
["Test Source", "Test content for RPC health check."],
None,
None,
None,
None,
None,
None,
]
],
temp.notebook_id,
[2],
None,
None,
],
source_path=f"/notebook/{temp.notebook_id}",
)
results.append(result)
print(format_check_with_success(result, "temp source added"))
if result.status == CheckStatus.OK:
temp.source_id = extract_id(data, 0, 0)
if not temp.source_id:
# Decoded response may carry residual credential-shaped substrings
# (cookies/CSRF tokens echoed in error payloads, etc.). Scrub the
# FULL repr before slicing — slicing first risks chopping a
# secret-shaped substring (e.g. ``cookie: SID=ab|cd``) at the
# 200-char boundary, leaving the prefix outside the scrub
# patterns. Scrub-then-truncate keeps the redaction intact even
# if the bytes after position 200 carried the matching anchor.
preview = scrub_secrets(repr(data))[:200]
print(f" WARNING: ADD_SOURCE ID extraction failed. Response: {preview}")
# Test ADD_SOURCE_FILE - registers file source intent (no actual upload needed)
# Params format: [[[filename]], notebook_id, [2], [1, None, ...]]
await asyncio.sleep(CALL_DELAY)
result = await test_rpc_method(
client,
auth,
RPCMethod.ADD_SOURCE_FILE,
[
[["test_file.pdf"]],
temp.notebook_id,
[2],
[1, None, None, None, None, None, None, None, None, None, [1]],
],
source_path=f"/notebook/{temp.notebook_id}",
)
results.append(result)
print(format_check_with_success(result, "file source registered"))
# Test START_FAST_RESEARCH - starts research task (verify RPC ID only)
# Params format: [[query, source_type], None, 1, notebook_id]
await asyncio.sleep(CALL_DELAY)
result = await test_rpc_method(
client,
auth,
RPCMethod.START_FAST_RESEARCH,
[["test query", 1], None, 1, temp.notebook_id], # 1 = Web search
source_path=f"/notebook/{temp.notebook_id}",
)
results.append(result)
print(format_check_with_success(result, "research started"))
# Test CREATE_NOTE - extract note_id from response[0]
# Params format: [notebook_id, "", [1], None, title]
await asyncio.sleep(CALL_DELAY)
result, data = await test_rpc_method_with_data(
client,
auth,
RPCMethod.CREATE_NOTE,
[temp.notebook_id, "", [1], None, "Test Note"],
source_path=f"/notebook/{temp.notebook_id}",
)
results.append(result)
print(format_check_with_success(result, "temp note created"))
if result.status == CheckStatus.OK:
temp.note_id = extract_id(data, 0)
# Test CREATE_ARTIFACT - main RPC for all artifact generation (flashcards are fast)
# Params for quiz: [[2], notebook_id, [None, None, 4, source_ids_triple, ...]]
if temp.source_id:
await asyncio.sleep(CALL_DELAY)
source_ids_triple = [[[temp.source_id]]]
result, data = await test_rpc_method_with_data(
client,
auth,
RPCMethod.CREATE_ARTIFACT,
[
[2],
temp.notebook_id,
[
None,
None,
4, # ArtifactTypeCode.QUIZ_FLASHCARD
source_ids_triple,
None,
None,
None,
None,
None,
[
None,
[
1, # Variant: flashcards (faster than quiz)
None, # instructions
None,
None,
None,
None,
[None, 1], # [difficulty, quantity] - FEWER
],
],
],
],
source_path=f"/notebook/{temp.notebook_id}",
)
results.append(result)
print(format_check_with_success(result, "flashcard generation triggered"))
if result.status == CheckStatus.OK:
# Artifact ID is at response[0][0]
temp.artifact_id = extract_id(data, 0, 0)
if not temp.artifact_id:
# Same scrub-then-truncate ordering as the ADD_SOURCE
# failure site upstream — slicing first risks chopping a
# cookie / CSRF token at the 200-char boundary and
# missing the scrub-pattern anchor.
preview = scrub_secrets(repr(data))[:200]
print(f" WARNING: CREATE_ARTIFACT ID extraction failed. Response: {preview}")
# Probe LIST_ARTIFACTS briefly so artifact generation gets a grace
# period before DELETE_ARTIFACT cleanup.
if temp.artifact_id:
# Probe for up to 30 seconds before cleanup.
max_polls = 15
poll_interval = 2.0
artifact_list_live = False
polls_done = 0
for _ in range(max_polls):
await asyncio.sleep(poll_interval)
polls_done += 1
# LIST_ARTIFACTS is the available liveness probe; there is no
# poll-by-ID RPC here.
poll_result = await test_rpc_method(
client,
auth,
RPCMethod.LIST_ARTIFACTS,
[[2], temp.notebook_id, 'NOT artifact.status = "ARTIFACT_STATUS_SUGGESTED"'],
source_path=f"/notebook/{temp.notebook_id}",
)
# We only need the method to succeed; poll results are omitted
# to keep output focused on the primary checks.
if poll_result.status == CheckStatus.OK:
artifact_list_live = True
break
if artifact_list_live:
print(f" Artifact list live after {polls_done * poll_interval:.0f}s polling")
else:
print(
f" Artifact list not live after {max_polls * poll_interval:.0f}s "
"(continuing anyway)"
)
else:
# Skip artifact tests - no source_id available
print("SKIP CREATE_ARTIFACT - No source_id available (source extraction failed)")
return temp
async def cleanup_temp_resources(
client: httpx.AsyncClient,
auth: AuthTokens,
temp: TempResources,
results: list[CheckResult],
) -> None:
"""Delete temporary resources and test DELETE RPC methods.
Tests DELETE_NOTE, DELETE_SOURCE, DELETE_ARTIFACT, and DELETE_NOTEBOOK RPCs.
Always attempts to delete the notebook, even if other cleanup operations fail.
"""
if not temp.notebook_id:
return
# Test DELETE_NOTE if we have a note (best effort - don't block notebook deletion)
if temp.note_id:
try:
await asyncio.sleep(CALL_DELAY)
result = await test_rpc_method(
client,
auth,
RPCMethod.DELETE_NOTE,
[temp.notebook_id, None, [temp.note_id]],
source_path=f"/notebook/{temp.notebook_id}",
)
results.append(result)
print(format_check_with_success(result, "temp note deleted"))
except Exception as e:
print(f"ERROR DELETE_NOTE - {e}")
# Test DELETE_SOURCE if we have a source (best effort)
if temp.source_id:
try:
await asyncio.sleep(CALL_DELAY)
result = await test_rpc_method(
client,
auth,
RPCMethod.DELETE_SOURCE,
[[[temp.source_id]]],
source_path=f"/notebook/{temp.notebook_id}",
)
results.append(result)
print(format_check_with_success(result, "temp source deleted"))
except Exception as e:
print(f"ERROR DELETE_SOURCE - {e}")
# Test DELETE_ARTIFACT if we have an artifact (best effort)
if temp.artifact_id:
try:
await asyncio.sleep(CALL_DELAY)
result = await test_rpc_method(
client,
auth,
RPCMethod.DELETE_ARTIFACT,
[[2], temp.artifact_id],
source_path=f"/notebook/{temp.notebook_id}",
)
results.append(result)
print(format_check_with_success(result, "temp artifact deleted"))
except Exception as e:
print(f"ERROR DELETE_ARTIFACT - {e}")
# ALWAYS delete notebook - this is critical to avoid orphaned notebooks
try:
await asyncio.sleep(CALL_DELAY)
result = await test_rpc_method(client, auth, RPCMethod.DELETE_NOTEBOOK, [temp.notebook_id])
results.append(result)
print(format_check_with_success(result, "temp notebook deleted"))
except Exception as e:
print(f"ERROR DELETE_NOTEBOOK - {e}")
print(f"WARNING: Notebook {temp.notebook_id} may need manual cleanup", file=sys.stderr)
async def run_health_check(full_mode: bool = False) -> tuple[list[CheckResult], CohortStatus]:
"""Run health check on all RPC methods.
Returns ``(results, cohort_status)``: the per-method ``CheckResult`` list plus
the ``sqTeoe`` studio-customization cohort tripwire verdict (reported and
exit-coded separately from RPC drift; see :class:`CohortStatus`).
"""
storage_path = resolve_storage_path()
cookies = load_auth()
notebook_id = os.environ.get("NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID") or os.environ.get(
"NOTEBOOKLM_GENERATION_NOTEBOOK_ID"
)
if not notebook_id and not full_mode:
print("WARNING: No notebook ID provided. Some methods will be skipped.", file=sys.stderr)
results: list[CheckResult] = []
temp_resources = TempResources()
cohort_status = CohortStatus.UNKNOWN
print("Fetching auth tokens...")
try:
csrf_token, session_id = await fetch_tokens(cookies, storage_path=storage_path)
except ValueError as e:
print(f"ERROR: {scrub_secrets(e)}", file=sys.stderr)
sys.exit(2)
except httpx.HTTPError as e:
# ``httpx`` exception strings can echo full request URLs including
# ``f.sid=<session_id>`` query params, so scrub before logging.
print(
f"ERROR: Network error while fetching auth tokens: {scrub_secrets(e)}",
file=sys.stderr,
)
sys.exit(2)
auth = AuthTokens(
cookies=cookies,
csrf_token=csrf_token,
session_id=session_id,
storage_path=storage_path,
authuser=get_authuser_for_storage(storage_path),
account_email=get_account_email_for_storage(storage_path),
)
print(f"Auth OK (CSRF token length: {len(auth.csrf_token)})")
print()
async with httpx.AsyncClient(timeout=30.0) as client:
try:
if full_mode:
print("Creating temp resources for full testing...")
temp_resources = await setup_temp_resources(client, auth, results)
if temp_resources.notebook_id:
notebook_id = temp_resources.notebook_id
print()
methods = list(RPCMethod)
total = len(methods)
print(f"Checking {total} RPC methods...")
print("=" * 60)
for i, method in enumerate(methods, 1):
result = await check_method(client, auth, method, notebook_id, full_mode)
results.append(result)
status_icon = STATUS_ICONS[result.status]
line = f"{status_icon:8} {method.name} ({result.expected_id})"
if result.error and result.status != CheckStatus.OK:
# Per-method live print of the error string runs BEFORE
# the summary scrub at the end of the script and BEFORE
# the workflow-level scrub on health-report.txt. Scrub
# at the live site too so the Actions log doesn't carry
# an unredacted copy in the streamed output.
line += f" - {scrub_secrets(result.error)}"
print(line)
if i < total and result.status != CheckStatus.SKIPPED:
await asyncio.sleep(CALL_DELAY)
# Probe the streamed-chat orchestration RPC. It is not an
# ``RPCMethod`` (it is a ``PATH_NOT_METHOD`` ``v1`` URL segment),
# so it is checked separately from the method-ID loop above — but
# its ``CheckResult`` flows through the same summary + exit-code
# machinery so chat drift fails the canary like any other probe.
chat_result = await check_chat_query(client, auth, notebook_id)
results.append(chat_result)
chat_icon = STATUS_ICONS[chat_result.status]
chat_line = f"{chat_icon:8} {chat_result.method.name} ({chat_result.expected_id})"
if chat_result.error and chat_result.status != CheckStatus.OK:
chat_line += f" - {scrub_secrets(chat_result.error)}"
print(chat_line)
# Studio-customization cohort tripwire. Distinct from the RPC-drift
# probes: GATED-null is the expected steady state (NOT a failure),
# MIGRATED is a loud signal that the customization surface flipped and
# the VideoStyle/format codes must be re-captured.
cohort_status, cohort_detail = await check_customization_cohort(
client, auth, notebook_id
)
print(
"COHORT sqTeoe customization choices: "
f"{cohort_status.value} - {scrub_secrets(cohort_detail)}"
)
finally:
if full_mode and temp_resources.notebook_id:
print()
print("Testing DELETE operations during cleanup...")
await cleanup_temp_resources(client, auth, temp_resources, results)
return results, cohort_status
# Substrings that mark an ERROR as a transient signal. Keep this list
# narrow on purpose: broadening it would mask real RPC drift.
#
# Currently classified as transient:
# * ``HTTP 429`` and gRPC ``RESOURCE_EXHAUSTED`` — explicit rate-limit
# signals from the backend.
# * ``API rate limit`` — catches the decoder's user-displayable messages
# raised as ``RateLimitError`` ("API rate limit exceeded..." and
# "API rate limit or quota exceeded..."). These reach the canary via
# the ``except RPCError`` parse-error branch in
# ``test_rpc_method_with_data`` and were previously misclassified.
# * ``ReadTimeout`` — ``httpx.ReadTimeout`` against Google's RPC
# endpoints is almost always server-side slowness, not an RPC
# contract change. It consistently passes on retry (see #1004 and
# prior occurrences on 2026-05-20). Only the ``httpx.ReadTimeout``
# class name is treated as transient — ``ConnectTimeout`` /
# ``WriteTimeout`` / ``PoolTimeout`` stay non-transient because they
# point at client- or network-side problems worth surfacing.
#
# Everything else (other timeouts, parse failures, unexpected HTTP
# status codes, schema mismatches) is still treated as a real failure
# so the nightly canary can flag silent breakage.
TRANSIENT_ERROR_MARKERS: tuple[str, ...] = (
"HTTP 429",
"RESOURCE_EXHAUSTED",
"API rate limit",
"ReadTimeout",
)
def is_transient_error(error_message: str | None) -> bool:
"""Return True if an ERROR result is a transient signal.
Transient signals (filtered out of the auto-open path):
* Rate-limit responses (HTTP 429, gRPC ``RESOURCE_EXHAUSTED``,
decoder ``RateLimitError`` messages).
* ``httpx.ReadTimeout`` against Google's RPC endpoints — these
are server-side flakes that pass on retry (issue #1004).
Anything else — other timeouts, parse failures, unexpected HTTP
status codes — is treated as a real failure that warrants
investigation.
"""
if not error_message:
return False
return any(marker in error_message for marker in TRANSIENT_ERROR_MARKERS)
def partition_errors(
results: list[CheckResult],
) -> tuple[list[CheckResult], list[CheckResult]]:
"""Split ERROR results into (non-transient, transient) lists."""
non_transient: list[CheckResult] = []
transient: list[CheckResult] = []
for r in results:
if r.status != CheckStatus.ERROR:
continue
if is_transient_error(r.error):
transient.append(r)
else:
non_transient.append(r)
return non_transient, transient
def compute_exit_code(
counts: Counter[CheckStatus],
non_transient_errors: list[CheckResult],
cohort_status: CohortStatus = CohortStatus.UNKNOWN,
) -> int:
"""Compute the script exit code from result counts.
Priority order (highest wins):
1. MISMATCH -> 1
2. AUTH -> 2 (signaled by the caller via sys.exit, never reached here)
3. non-transient ERROR -> 3
4. cohort MIGRATED -> 4 (sqTeoe tripwire flipped; loud but not RPC drift)
5. OK -> 0
The cohort flip sits *below* the RPC-drift codes so a run that has both real
drift AND a cohort flip still surfaces the higher-severity drift exit; the
flip is reported in the summary either way.
"""
if counts[CheckStatus.MISMATCH] > 0:
return 1
if non_transient_errors:
return 3
if cohort_status == CohortStatus.MIGRATED:
return 4
return 0
def print_summary(
results: list[CheckResult],
cohort_status: CohortStatus = CohortStatus.UNKNOWN,
) -> int:
"""Print summary and return exit code."""
print()
print("=" * 60)
print("SUMMARY")
print("=" * 60)
counts = Counter(r.status for r in results)
total = len(results)
tested = total - counts[CheckStatus.SKIPPED]
non_transient_errors, transient_errors = partition_errors(results)
non_transient_count = len(non_transient_errors)
transient_count = len(transient_errors)
print(f"TESTED: {tested}/{total} methods")
print(f"OK: {counts[CheckStatus.OK]}/{tested}")
print(f"MISMATCH: {counts[CheckStatus.MISMATCH]}/{tested}")
print(
f"ERROR: {counts[CheckStatus.ERROR]}/{tested} "
f"(non-transient: {non_transient_count}, transient: {transient_count})"
)
# Print details for mismatches
mismatches = [r for r in results if r.status == CheckStatus.MISMATCH]
if mismatches:
print()
print("MISMATCH DETAILS:")
print("-" * 40)
for r in mismatches:
print(f" {r.method.name}:")
print(f" Expected: '{r.expected_id}'")
print(f" Found: {r.found_ids}")
print(f" Action: Update RPCMethod.{r.method.name} in src/notebooklm/rpc/types.py")
print()
# Print details for errors, split into non-transient (real failures)
# and transient (rate-limit) buckets so the cron output is actionable.
if non_transient_errors or transient_errors:
print()
print("ERROR DETAILS:")
print("-" * 40)
for r in non_transient_errors:
# ``r.error`` is a free-form error string produced by the RPC
# call paths; if the upstream library ever quotes a request
# URL or cookie jar in its message, the workflow's later
# file scrub catches it but the live Actions log would not.
# Belt-and-braces: scrub at the print site too.
print(f" [non-transient] {r.method.name} ({r.expected_id}): {scrub_secrets(r.error)}")
for r in transient_errors:
print(f" [transient] {r.method.name} ({r.expected_id}): {scrub_secrets(r.error)}")
print()
# Cohort tripwire line. GATED-null is the expected steady state (a PASS, not
# a failure); MIGRATED is the loud flip the canary exists to surface.
print(f"COHORT: sqTeoe customization choices = {cohort_status.value}")
# Return exit code.
# Priority: MISMATCH (1) > non-transient ERROR (3) > cohort MIGRATED (4) > OK (0).
# AUTH (2) is signaled earlier via sys.exit(2) and never reaches here.
exit_code = compute_exit_code(counts, non_transient_errors, cohort_status)
if exit_code == 1:
print("RESULT: FAIL - RPC ID mismatches detected")
return 1
if exit_code == 3:
affected = ", ".join(r.method.name for r in non_transient_errors)
print(f"RESULT: FAIL - non-transient ERROR detected in methods: {affected}")
print(" These are real failures (not rate-limit transients).")
return 3
if exit_code == 4:
print("RESULT: COHORT FLIP - sqTeoe returned non-null (studio customization migrated)")
print(" Re-capture VideoStyle / format codes in src/notebooklm/rpc/types.py.")
return 4
if transient_errors:
print("RESULT: PASS - Only transient errors observed (rate-limits / ReadTimeouts)")
print(" Review ERROR DETAILS above for affected methods.")
return 0
print("RESULT: PASS - All tested RPC methods OK")
return 0
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="RPC Health Check - Verify NotebookLM RPC method IDs"
)
parser.add_argument(
"--full",
action="store_true",
help="Full mode: create temp notebook to test create/delete operations",
)
args = parser.parse_args()
mode_str = "FULL" if args.full else "QUICK"
print(f"RPC Health Check ({mode_str} mode)")
print("=" * 60)
print()
results, cohort_status = asyncio.run(run_health_check(full_mode=args.full))
return print_summary(results, cohort_status)
if __name__ == "__main__":
sys.exit(main())