Files
teng-lin--notebooklm-py/tests/integration/test_side_effects_idempotency.py
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

525 lines
21 KiB
Python

"""Side-effect RPC idempotency regression tests (Tier 9, P0-3 + P1-2).
This file validates the Wave-2 classifications added to
``IDEMPOTENCY_REGISTRY`` for the five mutating side-effect RPCs:
* ``DELETE_NOTEBOOK`` → ``IDEMPOTENT_SET_OP`` (delete is idempotent)
* ``DELETE_SOURCE`` → ``IDEMPOTENT_SET_OP``
* ``DELETE_ARTIFACT`` → ``IDEMPOTENT_SET_OP``
* ``REFRESH_SOURCE`` → ``AT_LEAST_ONCE_ACCEPTED`` (extra fetch is acceptable)
* ``SHARE_NOTEBOOK`` → ``PROBE_THEN_CREATE`` (suppresses blind retry; uses
``GET_SHARE_STATUS`` as the probe RPC if a future
wrapper is added)
It also exercises the P1-2 fix to ``NotebooksAPI.create``: a
``NetworkError`` during the probe ``list()`` MUST propagate, not be
silently coerced to "no match", so the caller learns the prior create
may have committed server-side and the retry loop won't duplicate the
resource.
Tests use ``httpx.MockTransport`` — no cassettes, no network. They are
opted out of the VCR tier enforcement via
``pytestmark = pytest.mark.allow_no_vcr``.
"""
from __future__ import annotations
import json
import logging
import httpx
import pytest
import notebooklm._runtime.helpers as _runtime_helpers
from notebooklm import NetworkError, NotebookLMClient
from notebooklm._idempotency import IDEMPOTENCY_REGISTRY, IdempotencyPolicy
from notebooklm.rpc import RPCMethod
from tests._fixtures.kernel_test_helpers import install_http_client_for_test
pytestmark = pytest.mark.allow_no_vcr
# ---------------------------------------------------------------------------
# Helpers — minimal batchexecute response builders + mock-transport client
# ---------------------------------------------------------------------------
def _wrb_response(rpc_id: str, payload: object) -> str:
"""Build a single-RPC batchexecute response body.
Mirrors the on-the-wire format used everywhere in the test suite:
``)]}}'\\n<len>\\n<chunk>\\n``.
"""
inner = json.dumps(payload)
chunk = json.dumps([["wrb.fr", rpc_id, inner, None, None]])
return f")]}}'\n{len(chunk)}\n{chunk}\n"
def _list_notebooks_response(notebooks: list[tuple[str, str]]) -> str:
"""Build a LIST_NOTEBOOKS response from ``[(notebook_id, title), ...]``."""
raw = [
[title, None, nb_id, "📘", None, [None, None, None, None, None, [1704067200, 0]]]
for nb_id, title in notebooks
]
return _wrb_response(RPCMethod.LIST_NOTEBOOKS.value, [raw])
def _make_client_with_transport(
transport: httpx.AsyncBaseTransport,
auth_tokens,
*,
server_error_max_retries: int = 3,
) -> NotebookLMClient:
"""Construct a ``NotebookLMClient`` wired to the supplied mock transport.
Bypasses the full ``ClientLifecycle.open()`` path so the test doesn't try
to build a real ``httpx.AsyncClient`` with cookies + connection pool.
"""
client = NotebookLMClient(
auth_tokens,
server_error_max_retries=server_error_max_retries,
)
install_http_client_for_test(
client._collaborators.kernel,
httpx.AsyncClient(
transport=transport,
headers={
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
),
)
return client
def _rpc_id_in_request(request: httpx.Request) -> str | None:
"""Extract the ``rpcids=`` query param from a batchexecute request URL."""
for key, value in request.url.params.multi_items():
if key == "rpcids":
return value
return None
# ===========================================================================
# Registry classifications — direct lookup
# ===========================================================================
def test_delete_notebook_classified_idempotent_set_op() -> None:
"""``DELETE_NOTEBOOK`` is an idempotent set-op (registry entry only)."""
entry = IDEMPOTENCY_REGISTRY.get_entry(RPCMethod.DELETE_NOTEBOOK)
assert entry.policy is IdempotencyPolicy.IDEMPOTENT_SET_OP
assert entry.notes # non-empty rationale
def test_delete_source_classified_idempotent_set_op() -> None:
"""``DELETE_SOURCE`` is an idempotent set-op."""
entry = IDEMPOTENCY_REGISTRY.get_entry(RPCMethod.DELETE_SOURCE)
assert entry.policy is IdempotencyPolicy.IDEMPOTENT_SET_OP
def test_delete_artifact_classified_idempotent_set_op() -> None:
"""``DELETE_ARTIFACT`` is an idempotent set-op."""
entry = IDEMPOTENCY_REGISTRY.get_entry(RPCMethod.DELETE_ARTIFACT)
assert entry.policy is IdempotencyPolicy.IDEMPOTENT_SET_OP
def test_refresh_source_classified_at_least_once_accepted() -> None:
"""``REFRESH_SOURCE`` accepts at-least-once retry semantics."""
entry = IDEMPOTENCY_REGISTRY.get_entry(RPCMethod.REFRESH_SOURCE)
assert entry.policy is IdempotencyPolicy.AT_LEAST_ONCE_ACCEPTED
def test_share_notebook_classified_probe_then_create() -> None:
"""``SHARE_NOTEBOOK`` is PROBE_THEN_CREATE — ``GET_SHARE_STATUS`` exists
as the server-side probe RPC, so a blind retry is unsafe and the
transport retry loop MUST be suppressed."""
entry = IDEMPOTENCY_REGISTRY.get_entry(RPCMethod.SHARE_NOTEBOOK)
assert entry.policy is IdempotencyPolicy.PROBE_THEN_CREATE
# ===========================================================================
# Delete RPCs keep today's retry behavior (IDEMPOTENT_SET_OP is silent)
# ===========================================================================
async def test_delete_notebook_retries_remain_enabled(
auth_tokens,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``IDEMPOTENT_SET_OP`` MUST be behavior-neutral: the transport's
inner retry loop continues to fire on 5xx — today's behavior is
preserved, the registry just documents *why* it is safe.
"""
request_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal request_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.DELETE_NOTEBOOK.value:
request_count += 1
return httpx.Response(502, text="bad gateway")
return httpx.Response(404, text="unexpected")
sleep_calls = 0
async def _no_sleep(_seconds: float) -> None:
nonlocal sleep_calls
sleep_calls += 1
# Object-form (ADR-0007): patch ``sleep`` on the ``asyncio`` module
# object that ``_runtime.helpers.resolve_sleep`` re-reads on every
# call. ``_runtime_helpers.asyncio`` IS the singleton ``asyncio``
# module, so this is functionally identical to the string-target
# form while staying off the forbidden-monkeypatch allowlist.
monkeypatch.setattr(_runtime_helpers.asyncio, "sleep", _no_sleep)
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens, server_error_max_retries=2)
try:
from notebooklm import ServerError
with pytest.raises(ServerError):
await client.notebooks.delete("nb_x")
# initial + 2 retries = 3 POSTs (IDEMPOTENT_SET_OP leaves caller-False alone)
assert request_count == 3, (
f"DELETE_NOTEBOOK with IDEMPOTENT_SET_OP expected 3 POSTs "
f"(initial + 2 retries), got {request_count}"
)
# Bite-check: the patched sleep was actually invoked between
# retries, proving the object-form patch reached the production
# ``resolve_sleep`` seam (2 retries → 2 backoff sleeps).
assert sleep_calls >= 1, "patched asyncio.sleep was never invoked"
finally:
await client._collaborators.kernel.get_http_client().aclose()
async def test_delete_source_retries_remain_enabled(
auth_tokens,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``DELETE_SOURCE`` retries continue under IDEMPOTENT_SET_OP."""
request_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal request_count
if _rpc_id_in_request(request) == RPCMethod.DELETE_SOURCE.value:
request_count += 1
return httpx.Response(502, text="bad gateway")
return httpx.Response(404, text="unexpected")
sleep_calls = 0
async def _no_sleep(_seconds: float) -> None:
nonlocal sleep_calls
sleep_calls += 1
# Object-form (ADR-0007): see ``test_delete_notebook_retries_remain_enabled``.
monkeypatch.setattr(_runtime_helpers.asyncio, "sleep", _no_sleep)
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens, server_error_max_retries=2)
try:
from notebooklm import ServerError
with pytest.raises(ServerError):
await client.sources.delete("nb_x", "src_x")
assert request_count == 3, f"expected 3 POSTs, got {request_count}"
# Bite-check: patched sleep observed between retries.
assert sleep_calls >= 1, "patched asyncio.sleep was never invoked"
finally:
await client._collaborators.kernel.get_http_client().aclose()
async def test_delete_artifact_retries_remain_enabled(
auth_tokens,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``DELETE_ARTIFACT`` retries continue under IDEMPOTENT_SET_OP."""
request_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal request_count
if _rpc_id_in_request(request) == RPCMethod.DELETE_ARTIFACT.value:
request_count += 1
return httpx.Response(502, text="bad gateway")
return httpx.Response(404, text="unexpected")
sleep_calls = 0
async def _no_sleep(_seconds: float) -> None:
nonlocal sleep_calls
sleep_calls += 1
# Object-form (ADR-0007): see ``test_delete_notebook_retries_remain_enabled``.
monkeypatch.setattr(_runtime_helpers.asyncio, "sleep", _no_sleep)
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens, server_error_max_retries=2)
try:
from notebooklm import ServerError
with pytest.raises(ServerError):
await client.artifacts.delete("nb_x", "art_x")
assert request_count == 3, f"expected 3 POSTs, got {request_count}"
# Bite-check: patched sleep observed between retries.
assert sleep_calls >= 1, "patched asyncio.sleep was never invoked"
finally:
await client._collaborators.kernel.get_http_client().aclose()
# ===========================================================================
# REFRESH_SOURCE — AT_LEAST_ONCE_ACCEPTED emits a rate-limited WARN
# ===========================================================================
async def test_refresh_source_emits_rate_limited_warn(
auth_tokens,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``REFRESH_SOURCE`` emits exactly one WARN to flag at-least-once
semantics, and the warn is rate-limited so 5 invocations produce
≤2 lines (mirrors the registry's per-(method, variant) throttle)."""
# Clear the rate-limit ledger so a window tripped by a prior test
# doesn't suppress the WARN we expect here.
import notebooklm._idempotency as idemp_mod
monkeypatch.setattr(idemp_mod, "_at_least_once_last_logged", {})
invocations = 5
refresh_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal refresh_count
if _rpc_id_in_request(request) == RPCMethod.REFRESH_SOURCE.value:
refresh_count += 1
# REFRESH_SOURCE's success response is a no-data null body
# (the API uses allow_null=True). Mirror that shape.
return httpx.Response(200, text=_wrb_response(RPCMethod.REFRESH_SOURCE.value, None))
return httpx.Response(404, text="unexpected")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens)
try:
with caplog.at_level(logging.WARNING, logger="notebooklm._idempotency"):
for _ in range(invocations):
ok = await client.sources.refresh("nb_x", "src_x")
assert ok is None # v0.8.0 (#1290): returns None on success
finally:
await client._collaborators.kernel.get_http_client().aclose()
warn_records = [
r
for r in caplog.records
if r.name.startswith("notebooklm._idempotency") and r.levelno >= logging.WARNING
]
assert refresh_count == invocations, f"transport saw {refresh_count} REFRESH_SOURCE calls"
assert 1 <= len(warn_records) <= 2, (
f"AT_LEAST_ONCE_ACCEPTED emitted {len(warn_records)} WARN lines for "
f"{invocations} calls; expected 1-2 (rate-limited)"
)
# The WARN message names REFRESH_SOURCE explicitly so operators can
# grep logs for the affected RPC.
assert any("REFRESH_SOURCE" in r.getMessage() for r in warn_records)
# ===========================================================================
# SHARE_NOTEBOOK — PROBE_THEN_CREATE suppresses the blind transport retry
# ===========================================================================
async def test_share_notebook_does_not_retry_on_5xx(
auth_tokens,
) -> None:
"""``SHARE_NOTEBOOK`` is PROBE_THEN_CREATE, which forces
``disable_internal_retries=True`` inside the executor — a 5xx MUST
surface immediately so the caller (or a future probe-then-create
wrapper) decides whether the ACL mutation landed before re-issuing.
Today a blind retry would risk re-sending invitation emails or
double-flipping public/private access; this test pins the policy.
"""
share_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal share_count
if _rpc_id_in_request(request) == RPCMethod.SHARE_NOTEBOOK.value:
share_count += 1
return httpx.Response(502, text="bad gateway")
return httpx.Response(404, text="unexpected")
# No sleep-seam patch is needed here: PROBE_THEN_CREATE forces
# ``disable_internal_retries=True`` → exactly 1 POST with no retry
# loop, so no backoff sleep ever fires. The assertion below
# (``share_count == 1``) is what pins the suppressed-retry policy;
# a regression to blind retries fails it directly (with real
# ``asyncio.sleep`` adding wall-time but still surfacing the bug).
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens, server_error_max_retries=5)
try:
from notebooklm import ServerError
with pytest.raises(ServerError):
await client.sharing.set_public("nb_x", True)
# PROBE_THEN_CREATE forces disable_internal_retries=True → exactly 1 POST.
# Even with server_error_max_retries=5, the registry suppresses retries.
assert share_count == 1, (
f"SHARE_NOTEBOOK with PROBE_THEN_CREATE expected 1 POST "
f"(no blind retry), got {share_count}"
)
finally:
await client._collaborators.kernel.get_http_client().aclose()
# ===========================================================================
# P1-2 — NotebooksAPI.create probe propagates NetworkError
# ===========================================================================
async def test_notebooks_create_probe_propagates_network_error(
auth_tokens,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A ``NetworkError`` raised by the probe ``list()`` MUST propagate
out of ``NotebooksAPI.create``, not be silently coerced to "no match".
Before the fix the probe's ``except Exception:`` clause swallowed
everything and returned ``None``, which let ``idempotent_create``
re-issue the create on the next attempt — potentially duplicating
the notebook if the original create actually committed server-side.
Test setup:
* LIST_NOTEBOOKS returns an empty baseline on the FIRST call so
``baseline_ids = set()`` (the create can proceed).
* CREATE_NOTEBOOK fails with 502 → the executor's retry loop is
disabled because CREATE_NOTEBOOK is registered PROBE_THEN_CREATE,
so the failure surfaces as a single ``ServerError`` (treated as
a transport failure by ``idempotent_create``).
* The probe call to LIST_NOTEBOOKS then fails with a *transport-
layer* connection error that translates to ``NetworkError``.
* ``NetworkError`` MUST propagate out instead of being swallowed.
"""
list_call_count = 0
create_call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal list_call_count, create_call_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.LIST_NOTEBOOKS.value:
list_call_count += 1
if list_call_count == 1:
# Baseline list — empty.
return httpx.Response(200, text=_list_notebooks_response([]))
# Probe list — simulate a transport-level connection failure.
# Raising httpx.ConnectError from the handler lets the client
# see it as a connection failure (translated to NetworkError).
raise httpx.ConnectError("simulated probe-time network drop")
if rpc_id == RPCMethod.CREATE_NOTEBOOK.value:
create_call_count += 1
return httpx.Response(502, text="bad gateway")
return httpx.Response(404, text="unexpected")
# Skip backoff sleeps so the test doesn't pay the inner-retry wall time
# on the probe's LIST_NOTEBOOKS retries (LIST_NOTEBOOKS is explicitly
# retry-safe, so the transport still retries 5xx/network errors there).
sleep_calls = 0
async def _no_sleep(_seconds: float) -> None:
nonlocal sleep_calls
sleep_calls += 1
# Object-form (ADR-0007): see ``test_delete_notebook_retries_remain_enabled``.
monkeypatch.setattr(_runtime_helpers.asyncio, "sleep", _no_sleep)
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens)
try:
with pytest.raises(NetworkError):
await client.notebooks.create("Some Title")
finally:
await client._collaborators.kernel.get_http_client().aclose()
# Bite-check: the retry-safe LIST_NOTEBOOKS / CREATE_NOTEBOOK 5xx path
# exercises the backoff sleep, so the patched seam was invoked —
# proving the object-form patch reached production ``resolve_sleep``.
assert sleep_calls >= 1, "patched asyncio.sleep was never invoked"
# Sanity check: the probe was actually attempted and the create fired
# once before the probe failed. LIST_NOTEBOOKS is retry-safe so the
# inner transport retry loop fires for the probe — we don't pin a
# precise count, only that the probe path was entered (>1 list call).
assert list_call_count >= 2, (
f"expected ≥2 LIST_NOTEBOOKS calls (baseline + probe), got {list_call_count}"
)
assert create_call_count >= 1, (
f"expected ≥1 CREATE_NOTEBOOK call before probe NetworkError, got {create_call_count}"
)
async def test_notebooks_create_probe_swallows_non_network_exception(
auth_tokens,
) -> None:
"""A non-network exception (decoding error, unexpected RPC failure)
during the probe MUST still be swallowed → return ``None`` →
``idempotent_create`` retries the create.
This pins the *contract* that the P1-2 fix surgically widened the
propagation only for ``NetworkError``: random other failures inside
the probe path stay best-effort, matching the original intent.
"""
list_call_count = 0
create_call_count = 0
nb_id_after_retry = "nb_after_retry"
title = "Retry Title"
def handler(request: httpx.Request) -> httpx.Response:
nonlocal list_call_count, create_call_count
rpc_id = _rpc_id_in_request(request)
if rpc_id == RPCMethod.LIST_NOTEBOOKS.value:
list_call_count += 1
if list_call_count == 1:
# Baseline — empty.
return httpx.Response(200, text=_list_notebooks_response([]))
# Probe — return a payload that won't decode into a notebook
# list. ``_wrb_response`` wraps a malformed inner payload so
# the decoder raises a DecodingError (NOT a NetworkError).
return httpx.Response(
200,
text=_wrb_response(RPCMethod.LIST_NOTEBOOKS.value, "definitely-not-a-list"),
)
if rpc_id == RPCMethod.CREATE_NOTEBOOK.value:
create_call_count += 1
if create_call_count == 1:
return httpx.Response(502, text="bad gateway")
# Second create succeeds — ``idempotent_create`` got the
# swallowed-None probe back and retried per contract.
return httpx.Response(
200,
text=_wrb_response(
RPCMethod.CREATE_NOTEBOOK.value,
[
title,
None,
nb_id_after_retry,
"📘",
None,
[None, None, None, None, None, [1704067200, 0]],
],
),
)
return httpx.Response(404, text="unexpected")
transport = httpx.MockTransport(handler)
client = _make_client_with_transport(transport, auth_tokens)
try:
notebook = await client.notebooks.create(title)
finally:
await client._collaborators.kernel.get_http_client().aclose()
assert notebook.id == nb_id_after_retry
assert create_call_count == 2, (
f"expected 2 CREATE_NOTEBOOK calls (initial + retry after non-network "
f"probe failure), got {create_call_count}"
)