Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions src/notebooklm_tools/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from . import constants
from .data_types import ConversationTurn
from .errors import ClientAuthenticationError as AuthenticationError
from .errors import ResourceExhaustedError, RPCDriftError, RPCError
from .errors import ResourceExhaustedError, RPCDriftError, RPCError, TransientBackendError
from .retry import (
DEFAULT_BASE_DELAY,
DEFAULT_MAX_DELAY,
Expand Down Expand Up @@ -144,6 +144,39 @@ def _extract_user_message(detail_data: Any, _depth: int = 0) -> str:
SOURCE_ADD_TIMEOUT = 120.0 # Extended timeout for all source operations


def _is_unreachable_failure(exc: Exception | None) -> bool:
"""Return whether a refresh failure indicates an unreachable backend.

A failed homepage refresh can mean either that credentials were rejected
or that the request never reached a usable NotebookLM backend. Only the
former should result in an authentication-expired message.
"""
if exc is None:
return False
if isinstance(exc, (httpx.TransportError, httpx.TimeoutException, OSError)):
return True
if isinstance(exc, httpx.HTTPStatusError):
return exc.response.status_code >= 500

text = str(exc).lower()
if "accounts.google.com" in text or "authentication expired" in text or "expired" in text:
return False
if re.search(r"\b5\d{2}\b", text):
return True
return any(
marker in text
for marker in (
"could not reach",
"network",
"timed out",
"timeout",
"connection",
"temporarily unavailable",
"dns",
)
)


class BaseClient:
"""Base client providing HTTP/RPC infrastructure for NotebookLM API.

Expand Down Expand Up @@ -1053,8 +1086,20 @@ def _call_rpc(
with self._state_lock:
self._client = None
return self._call_rpc(rpc_id, params, path, timeout, _retry=True)
except ValueError:
# CSRF refresh failed (cookies expired) - continue to layer 2
except (ValueError, httpx.HTTPError, OSError) as exc:
# A transport or 5xx failure is not evidence that credentials
# expired. Retrying auth would produce the wrong user guidance
# and may launch a needless interactive login.
if _is_unreachable_failure(exc):
raise TransientBackendError(
"Could not reach NotebookLM while verifying the session.",
hint=(
"Check your connection and retry; your saved credentials may still "
"be valid."
),
) from exc
# A redirect to accounts.google.com or an explicit expiry
# message remains a genuine authentication failure.
pass

# Layer 2 & 3: Reload from disk or run headless auth (deep retry)
Expand Down
9 changes: 9 additions & 0 deletions src/notebooklm_tools/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ def __init__(self, artifact_id: str, artifact_type: str = "artifact"):
self.artifact_type = artifact_type


class TransientBackendError(NotebookLMError):
"""Raised when the backend cannot be reached to verify authentication.

This is deliberately not an authentication error: the saved credentials
may still be valid, and asking the user to log in again cannot repair a
transport or backend outage.
"""


class ClientAuthenticationError(Exception):
"""Raised when authentication fails (HTTP 401/403 or RPC Error 16).

Expand Down
28 changes: 28 additions & 0 deletions tests/cli/test_cli_main_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Tests for top-level CLI error rendering."""

import pytest

from notebooklm_tools.cli import main
from notebooklm_tools.core.errors import TransientBackendError


def test_cli_main_renders_transient_backend_error_without_traceback(monkeypatch, capsys):
"""Transient backend failures should be a friendly CLI error, not a traceback."""

def raise_transient_error():
raise TransientBackendError(
"Could not reach NotebookLM while verifying the session.",
hint="Check your connection and retry.",
)

monkeypatch.setattr(main, "app", raise_transient_error)
monkeypatch.setattr("notebooklm_tools.cli.utils.print_update_notification", lambda: None)

with pytest.raises(SystemExit) as exc_info:
main.cli_main()

output = capsys.readouterr().out
assert exc_info.value.code == 1
assert "Authentication Error" not in output
assert "Could not reach NotebookLM" in output
assert "Check your connection and retry" in output
30 changes: 30 additions & 0 deletions tests/core/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any
from unittest.mock import MagicMock, patch

import httpx
import pytest


Expand Down Expand Up @@ -35,6 +36,35 @@ def test_base_client_init_with_csrf():
assert client.csrf_token == "test_token"


@pytest.mark.parametrize(
"failure",
[
ValueError("Failed to fetch NotebookLM page: HTTP 503"),
httpx.ReadTimeout("The read operation timed out"),
OSError("dns lookup failed"),
],
)
def test_unreachable_failure_classifies_transport_and_server_errors(failure):
"""Backend transport and 5xx failures must not be treated as auth expiry."""
from notebooklm_tools.core.base import _is_unreachable_failure

assert _is_unreachable_failure(failure) is True


@pytest.mark.parametrize(
"failure",
[
ValueError("Authentication expired. accounts.google.com login redirect"),
ValueError("Authentication expired"),
],
)
def test_unreachable_failure_rejects_explicit_auth_expiry(failure):
"""Explicit rejection evidence must continue through auth recovery."""
from notebooklm_tools.core.base import _is_unreachable_failure

assert _is_unreachable_failure(failure) is False


def test_build_request_body():
"""Test building RPC request body."""
from notebooklm_tools.core.base import BaseClient
Expand Down
15 changes: 15 additions & 0 deletions tests/core/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
NotebookLMError,
ResourceExhaustedError,
RPCError,
TransientBackendError,
)


Expand Down Expand Up @@ -59,6 +60,19 @@ def test_client_authentication_error():
assert "Session expired" in str(err)


def test_transient_backend_error_is_not_authentication_error():
"""Transient backend failures expose a retry hint without prompting login."""
err = TransientBackendError(
"Could not reach NotebookLM.",
hint="Check your connection and retry.",
)

assert isinstance(err, NotebookLMError)
assert not isinstance(err, ClientAuthenticationError)
assert err.message == "Could not reach NotebookLM."
assert err.hint == "Check your connection and retry."


def test_exception_hierarchy():
"""Test exception inheritance chain."""
assert issubclass(ArtifactError, NotebookLMError)
Expand All @@ -68,6 +82,7 @@ def test_exception_hierarchy():
assert issubclass(ArtifactNotFoundError, ArtifactError)
# ClientAuthenticationError is separate from NotebookLMError
assert issubclass(ClientAuthenticationError, Exception)
assert issubclass(TransientBackendError, NotebookLMError)
# ResourceExhaustedError is a subclass of RPCError
assert issubclass(ResourceExhaustedError, RPCError)
assert issubclass(ResourceExhaustedError, NotebookLMError)
Expand Down
47 changes: 47 additions & 0 deletions tests/test_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest

from notebooklm_tools.core.client import AuthenticationError, NotebookLMClient
from notebooklm_tools.core.errors import TransientBackendError


@pytest.fixture
Expand Down Expand Up @@ -76,6 +77,52 @@ def test_auto_retry_on_401(self, mock_client):
# Verify post was called twice
assert http_client.post.call_count == 2

def test_auth_recovery_reports_unreachable_backend(self, mock_client):
"""A failed homepage refresh must not be reported as expired auth."""
with (
patch.object(mock_client, "_get_client") as mock_get_client,
patch.object(
mock_client,
"_refresh_auth_tokens",
side_effect=ValueError("Failed to fetch NotebookLM page: HTTP 503"),
),
patch.object(mock_client, "_try_reload_or_headless_auth", return_value=False),
):
http_client = MagicMock(spec=httpx.Client)
mock_get_client.return_value = http_client

req = httpx.Request("POST", "https://notebooklm.google.com/batchexecute")
resp = httpx.Response(401, request=req)
http_client.post.side_effect = httpx.HTTPStatusError(
"Unauthorized", request=req, response=resp
)

with pytest.raises(TransientBackendError, match="Could not reach NotebookLM"):
mock_client._call_rpc("rLM1Ne", [])

def test_auth_recovery_keeps_expiry_as_authentication_failure(self, mock_client):
"""A login redirect remains an authentication failure."""
with (
patch.object(mock_client, "_get_client") as mock_get_client,
patch.object(
mock_client,
"_refresh_auth_tokens",
side_effect=ValueError("Authentication expired. accounts.google.com"),
),
patch.object(mock_client, "_try_reload_or_headless_auth", return_value=False),
):
http_client = MagicMock(spec=httpx.Client)
mock_get_client.return_value = http_client

req = httpx.Request("POST", "https://notebooklm.google.com/batchexecute")
resp = httpx.Response(401, request=req)
http_client.post.side_effect = httpx.HTTPStatusError(
"Unauthorized", request=req, response=resp
)

with pytest.raises(AuthenticationError, match="Authentication expired"):
mock_client._call_rpc("rLM1Ne", [])

def test_auto_retry_on_rpc_error_16(self, mock_client):
"""Test that client refreshes tokens and retries on RPC Error 16."""

Expand Down
Loading