teng-lin--notebooklm-py
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
230 行
9.0 KiB
Python
230 行
9.0 KiB
Python
"""Regression test for default ``rate_limit_max_retries=3`` with backoff.
|
||
|
||
Pre-fix, ``rate_limit_max_retries`` defaulted to ``0`` so any 429 raised
|
||
``RateLimitError`` immediately. The CLI silently bumped the value, but
|
||
programmatic users had to discover and opt in. Diverges from "smart
|
||
retry" SDK norms.
|
||
|
||
Post-fix:
|
||
- ``NotebookLMClient.__init__`` and ``NotebookLMClient.from_storage`` default
|
||
``rate_limit_max_retries`` to ``3``.
|
||
- ``RuntimeTransport.perform_authed_post`` falls back to capped exponential backoff
|
||
(start 1s, cap 30s, ±20% jitter) when a 429 lacks a parseable
|
||
``Retry-After`` header, so the new default is useful even when the
|
||
server omits the hint.
|
||
- ``disable_internal_retries=True`` still suppresses BOTH the
|
||
429 and the 5xx/network retry loops for mutating create RPCs whose
|
||
retries would risk duplicate-resource creation.
|
||
|
||
Test plan:
|
||
1. Default budget retries 3 times on 429 then succeeds on the 4th response.
|
||
2. Default budget exhausts: four 429s in a row raise ``RateLimitError``
|
||
after exactly 3 sleeps (initial + 3 retries = 4 total POSTs).
|
||
3. ``disable_internal_retries=True`` suppresses the 429 retry loop even
|
||
under the new positive default (B2 coordination check).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from notebooklm import NotebookLMClient, RateLimitError
|
||
from notebooklm.rpc import RPCMethod
|
||
from tests._fixtures.kernel_test_helpers import install_http_client_for_test
|
||
from tests.integration.conftest import install_post_as_stream
|
||
|
||
# Uses synthetic HTTPX responses via mock — no cassette, no real HTTP.
|
||
pytestmark = pytest.mark.allow_no_vcr
|
||
|
||
|
||
_DUMMY_REQUEST = httpx.Request("POST", "https://example.invalid/batchexecute")
|
||
|
||
|
||
def _build_429(retry_after: str | None = "1") -> httpx.Response:
|
||
"""Construct a 429 response with an optional Retry-After header.
|
||
|
||
Returns a real ``httpx.Response`` (not a MagicMock) so the transport
|
||
pipeline's ``raise_for_status()`` / ``response.headers.get`` paths
|
||
behave exactly as in production. A dummy ``httpx.Request`` is
|
||
attached so ``raise_for_status()`` can raise ``HTTPStatusError``
|
||
instead of complaining about a missing request.
|
||
"""
|
||
headers = {"retry-after": retry_after} if retry_after is not None else {}
|
||
return httpx.Response(429, headers=headers, text="rate limited", request=_DUMMY_REQUEST)
|
||
|
||
|
||
def _build_200_list_notebooks() -> httpx.Response:
|
||
"""Minimal valid batchexecute response that decodes to an empty list."""
|
||
inner = json.dumps([[]])
|
||
chunk = json.dumps([["wrb.fr", RPCMethod.LIST_NOTEBOOKS.value, inner, None, None]])
|
||
body = f")]}}'\n{len(chunk)}\n{chunk}\n"
|
||
return httpx.Response(200, text=body, request=_DUMMY_REQUEST)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_default_retries_succeed_after_three_429s(auth_tokens) -> None:
|
||
"""Default ``rate_limit_max_retries=3`` retries 3 times then succeeds.
|
||
|
||
Sequence: 429 → 429 → 429 → 200. Without explicit configuration,
|
||
the client must absorb the three 429s and succeed on the 4th POST.
|
||
"""
|
||
mock_post = AsyncMock(
|
||
side_effect=[
|
||
_build_429("1"),
|
||
_build_429("1"),
|
||
_build_429("1"),
|
||
_build_200_list_notebooks(),
|
||
]
|
||
)
|
||
|
||
# NotebookLMClient default — NO ``rate_limit_max_retries`` kwarg.
|
||
client = NotebookLMClient(auth_tokens)
|
||
assert client._composed.chain_host._rate_limit_max_retries == 3, (
|
||
"rate_limit_max_retries default must be 3; check that NotebookLMClient.__init__ "
|
||
"forwards the runtime default."
|
||
)
|
||
|
||
mock_http = AsyncMock(spec=httpx.AsyncClient)
|
||
mock_http.post = mock_post
|
||
install_post_as_stream(None, mock_http, mock_post)
|
||
install_http_client_for_test(client._collaborators.kernel, mock_http)
|
||
|
||
with patch("asyncio.sleep", AsyncMock()) as mock_sleep:
|
||
result = await client.notebooks.list()
|
||
|
||
assert result == []
|
||
assert mock_post.await_count == 4, (
|
||
f"Expected 1 initial POST + 3 retries = 4 total, got {mock_post.await_count}"
|
||
)
|
||
assert mock_sleep.await_count == 3, (
|
||
f"Expected 3 sleeps (one per retry), got {mock_sleep.await_count}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_default_retries_exhausted_raises_rate_limit_error(auth_tokens) -> None:
|
||
"""Default budget exhausted on the 4th 429 raises ``RateLimitError``.
|
||
|
||
Sequence: 429 × 4. With default=3, initial + 3 retries = 4 total
|
||
POSTs. The 4th 429 has no remaining budget so ``RateLimitError``
|
||
bubbles up.
|
||
"""
|
||
mock_post = AsyncMock(return_value=_build_429("1"))
|
||
|
||
client = NotebookLMClient(auth_tokens)
|
||
assert client._composed.chain_host._rate_limit_max_retries == 3
|
||
|
||
mock_http = AsyncMock(spec=httpx.AsyncClient)
|
||
mock_http.post = mock_post
|
||
install_post_as_stream(None, mock_http, mock_post)
|
||
install_http_client_for_test(client._collaborators.kernel, mock_http)
|
||
|
||
with patch("asyncio.sleep", AsyncMock()) as mock_sleep, pytest.raises(RateLimitError):
|
||
await client.notebooks.list()
|
||
|
||
assert mock_post.await_count == 4, (
|
||
f"Initial + 3 retries = 4 POSTs before raise; got {mock_post.await_count}"
|
||
)
|
||
assert mock_sleep.await_count == 3, (
|
||
f"3 retries -> 3 sleeps before raise; got {mock_sleep.await_count}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_default_retries_use_exponential_backoff_when_header_missing(
|
||
auth_tokens,
|
||
) -> None:
|
||
"""No ``Retry-After`` header on 429 → capped exponential backoff fallback.
|
||
|
||
Pre-fix, a 429 without ``Retry-After`` raised immediately even
|
||
with budget>0. Post-fix, the retry loop falls back to ``min(2 **
|
||
attempt, 30)`` seconds with ±20% jitter so the new positive default
|
||
is useful when Google omits the hint.
|
||
|
||
Sleeps are checked to be in the ``[0.8, 1.2]``, ``[1.6, 2.4]``,
|
||
``[3.2, 4.8]`` ranges (1, 2, 4 ± 20%).
|
||
"""
|
||
mock_post = AsyncMock(
|
||
side_effect=[
|
||
_build_429(retry_after=None),
|
||
_build_429(retry_after=None),
|
||
_build_429(retry_after=None),
|
||
_build_200_list_notebooks(),
|
||
]
|
||
)
|
||
|
||
client = NotebookLMClient(auth_tokens)
|
||
mock_http = AsyncMock(spec=httpx.AsyncClient)
|
||
mock_http.post = mock_post
|
||
install_post_as_stream(None, mock_http, mock_post)
|
||
install_http_client_for_test(client._collaborators.kernel, mock_http)
|
||
|
||
sleep_calls: list[float] = []
|
||
|
||
async def _record_sleep(seconds: float) -> None:
|
||
sleep_calls.append(seconds)
|
||
|
||
with patch("asyncio.sleep", side_effect=_record_sleep):
|
||
result = await client.notebooks.list()
|
||
|
||
assert result == []
|
||
assert mock_post.await_count == 4
|
||
assert len(sleep_calls) == 3, f"Expected 3 backoff sleeps, got {len(sleep_calls)}"
|
||
# Backoff schedule: 2**0=1, 2**1=2, 2**2=4 — each with ±20% jitter,
|
||
# floored at 0.1s.
|
||
assert 0.8 <= sleep_calls[0] <= 1.2, f"attempt 1 backoff out of range: {sleep_calls[0]}"
|
||
assert 1.6 <= sleep_calls[1] <= 2.4, f"attempt 2 backoff out of range: {sleep_calls[1]}"
|
||
assert 3.2 <= sleep_calls[2] <= 4.8, f"attempt 3 backoff out of range: {sleep_calls[2]}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_disable_internal_retries_skips_429_loop_under_new_default(
|
||
auth_tokens,
|
||
) -> None:
|
||
"""B2 coordination: ``disable_internal_retries=True`` skips 429 retries.
|
||
|
||
The idempotency hardening introduced ``disable_internal_retries`` for
|
||
mutating create RPCs (CREATE_NOTEBOOK, ADD_SOURCE) where naive
|
||
re-POST risks duplicating the resource. The rate-limit fix raises
|
||
the default ``rate_limit_max_retries`` to 3, so the idempotency gate
|
||
must continue to short-circuit the 429 path — otherwise mutating
|
||
creates would silently inherit the new default and the idempotency
|
||
safety net would no longer apply.
|
||
|
||
This test exercises ``RuntimeTransport.perform_authed_post`` directly with
|
||
``disable_internal_retries=True`` and verifies the very first 429
|
||
raises ``TransportRateLimited`` (which the API layer translates
|
||
into ``RateLimitError``) without sleeping.
|
||
"""
|
||
from notebooklm._transport_errors import TransportRateLimited
|
||
|
||
mock_post = AsyncMock(return_value=_build_429("1"))
|
||
|
||
client = NotebookLMClient(auth_tokens)
|
||
assert client._composed.chain_host._rate_limit_max_retries == 3
|
||
|
||
mock_http = AsyncMock(spec=httpx.AsyncClient)
|
||
mock_http.post = mock_post
|
||
install_post_as_stream(None, mock_http, mock_post)
|
||
install_http_client_for_test(client._collaborators.kernel, mock_http)
|
||
|
||
def _build_request(_snap):
|
||
return ("https://example.invalid/x", b"body", None)
|
||
|
||
with patch("asyncio.sleep", AsyncMock()) as mock_sleep, pytest.raises(TransportRateLimited):
|
||
await client._composed.transport.perform_authed_post(
|
||
build_request=_build_request,
|
||
log_label="test",
|
||
disable_internal_retries=True,
|
||
)
|
||
|
||
assert mock_post.await_count == 1, (
|
||
f"disable_internal_retries=True must short-circuit the 429 retry "
|
||
f"loop even with budget=3; got {mock_post.await_count} POSTs"
|
||
)
|
||
assert mock_sleep.await_count == 0, "No backoff sleep should occur when retries are disabled"
|