项目文件夹

文件
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

230 行
9.0 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""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"