Files
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

258 lines
13 KiB
Python

"""Error-path VCR cassettes via the synthetic-error plumbing.
SYNTHETIC error response — validates client exception mapping, not real Google
error shapes. The cassettes in this module carry the canonical synthetic-error
shapes from :mod:`tests.cassette_patterns.build_synthetic_error_response`
(the plumbing landed in PR #638): minimal JSON bodies whose ONLY purpose is
to drive the client's HTTP-status exception-mapping branches — status code +
a stub body, NOT Google's actual error response semantics.
Three modes are exercised here:
- ``error_synthetic_429_rate_limit.yaml`` → :class:`RateLimitError` after the
retry budget is exhausted.
- ``error_synthetic_500_server.yaml`` → :class:`ServerError` after the
retry budget is exhausted.
- ``error_synthetic_stale_csrf.yaml`` → triggers ``refresh_auth`` once,
then the second 400 surfaces as :class:`ClientError` (we replay TWO failing
POSTs from the cassette to capture the refresh-retry sequence end-to-end;
the assertion is that the refresh path FIRED, not that it succeeded).
Why we don't validate Google's real error shapes here
-----------------------------------------------------
The cassettes are SYNTHETIC — the bodies and headers are constructed from
``build_synthetic_error_response(mode)``, not captured from a live Google
response. They prove that the client's exception-mapping code raises the right
type for each transport-layer status code, NOT that those status codes are
shaped exactly like what Google emits in production. If you need to validate
a real-world error shape (e.g. a quota-exhaustion 429 with a real
``Retry-After`` header and Google-flavored body), record it live instead.
Recording note (maintainers)
----------------------------
As of Tier-12 PR 12.6, synthetic-error substitution lives in
:class:`notebooklm._middleware.error_injection.ErrorInjectionMiddleware`
at the chain layer — well above the ``httpx`` transport. VCR's record
hook patches ``httpcore.AsyncConnectionPool.handle_async_request`` (below
httpx), so the chain short-circuit happens before VCR ever sees the
request: the wrapper bypasses the record hook entirely. As a consequence
these cassettes are hand-written from the canonical synthetic shapes in
``tests/cassette_patterns.py`` rather than captured by running the tests
under ``NOTEBOOKLM_VCR_RECORD=1``. The replay path is unaffected — VCR
returns the cassette's synthetic response to the client's httpx pipeline
normally, and the exception-mapping branches fire as they would for a
real upstream error.
The ``@pytest.mark.synthetic_error("<mode>")`` marker is intentionally NOT
used here: it would activate the chain middleware during replay too,
short-circuiting VCR and making the cassette decorative. Leaving the env
var unset lets VCR's cassette drive the response, which is the behavior
we want the replay tests to exercise.
See ``docs/development.md`` (section "Synthetic error cassettes") and
``tests/cassette_patterns.py:build_synthetic_error_response`` for the canonical
synthetic shapes these cassettes carry.
"""
from __future__ import annotations
import pytest
from notebooklm import NotebookLMClient
from notebooklm.auth import AuthTokens
from notebooklm.exceptions import (
ClientError,
RateLimitError,
ServerError,
)
from tests.integration.conftest import skip_no_cassettes
from tests.vcr_config import notebooklm_vcr
# All tests in this module are VCR-tier. Skipped when cassettes are absent and
# we're not in record mode (``NOTEBOOKLM_VCR_RECORD=1``).
pytestmark = [pytest.mark.vcr, skip_no_cassettes]
def _synthetic_auth() -> AuthTokens:
"""Mock ``AuthTokens`` for the cassette-driven replay path.
The cassette responses are synthetic, so the request never reaches a real
server — the cookie / CSRF / session values are never validated. Mock
values are sufficient and let these tests run in CI without any auth
fixture. Mirrors the pattern in ``test_auth_refresh_vcr.py``.
"""
return AuthTokens(
cookies={
"SID": "vcr_mock_sid",
"HSID": "vcr_mock_hsid",
"SSID": "vcr_mock_ssid",
"APISID": "vcr_mock_apisid",
"SAPISID": "vcr_mock_sapisid",
},
csrf_token="vcr_mock_csrf",
session_id="vcr_mock_session",
)
class TestErrorPaths:
"""Replay synthetic-error cassettes and assert client exception mapping."""
@pytest.mark.asyncio
async def test_429_rate_limit(self) -> None:
"""SYNTHETIC error response — validates client exception mapping, not real Google error shapes.
Replays ``error_synthetic_429_rate_limit.yaml``: a single batchexecute
POST returns HTTP 429 with a ``Retry-After: 1`` header and a minimal
``{"error": {"code": 429, ...}}`` body. With the rate-limit retry
budget set to 0 on the client core, the first 429 surfaces directly
as :class:`RateLimitError` (the documented
``TransportRateLimited`` → ``RpcExecutor.rpc_call`` exception handler).
"""
client = NotebookLMClient(_synthetic_auth())
# Disable rate-limit retries so the single synthetic 429 in the
# cassette surfaces immediately as RateLimitError. The cassette only
# has ONE interaction; with the default retry budget the client would
# ask for a second cassette response that doesn't exist and VCR would
# raise ``CannotOverwriteExistingCassetteException``.
client._composed.chain_host._rate_limit_max_retries = 0
with notebooklm_vcr.use_cassette("error_synthetic_429_rate_limit.yaml") as cassette:
async with client:
with pytest.raises(RateLimitError) as exc_info:
await client.notebooks.list()
# The exception carries the method id and parsed ``Retry-After`` value
# — both pieces of context the client surfaces to callers (see the
# ``RateLimitError`` constructor in ``src/notebooklm/exceptions.py``).
# Asserting both makes a regression where the mapping drops one of
# them fail loudly instead of silently degrading the error message.
assert exc_info.value.method_id is not None
assert exc_info.value.retry_after == 1
# Cassette played EXACTLY one interaction: the failing POST. Asserting
# equality (not >=) is what catches a regression where the disabled
# retry budget silently re-enables itself and the client double-asks
# for the same cassette entry.
assert cassette.play_count == 1, (
f"Expected exactly 1 cassette interaction to play; got {cassette.play_count}"
)
@pytest.mark.asyncio
async def test_5xx_server_error(self) -> None:
"""SYNTHETIC error response — validates client exception mapping, not real Google error shapes.
Replays ``error_synthetic_500_server.yaml``: a single batchexecute POST
returns HTTP 500 with a minimal ``{"error": {"code": 500, ...}}`` body.
With the server-error retry budget set to 0 on the client core, the
first 500 surfaces as :class:`ServerError` via the
``TransportServerError`` → ``RpcExecutor.raise_rpc_error_from_http_status``
chain.
"""
client = NotebookLMClient(_synthetic_auth())
# Disable 5xx retries so the single synthetic 500 in the cassette
# surfaces immediately as ServerError. The retry-loop wiring itself
# is exercised separately by the unit tests in
# ``test_rate_limit_retry.py`` — here we focus on the terminal
# exception-mapping branch.
client._composed.chain_host._server_error_max_retries = 0
with notebooklm_vcr.use_cassette("error_synthetic_500_server.yaml") as cassette:
async with client:
with pytest.raises(ServerError) as exc_info:
await client.notebooks.list()
assert exc_info.value.status_code == 500
assert exc_info.value.method_id is not None
# Cassette played EXACTLY one interaction (the failing POST). Catches
# a regression where disabled retry budget silently re-enables itself.
assert cassette.play_count == 1, (
f"Expected exactly 1 cassette interaction to play; got {cassette.play_count}"
)
@pytest.mark.asyncio
async def test_expired_csrf_triggers_refresh(self) -> None:
"""SYNTHETIC error response — validates client exception mapping, not real Google error shapes.
Replays ``error_synthetic_stale_csrf.yaml``: the first batchexecute
POST returns HTTP 400 (NotebookLM's documented stale-CSRF response —
see :func:`notebooklm._runtime.helpers.is_auth_error`); the client's auth-refresh
branch fires once via the stub callback installed below; the second
cassette interaction returns the same synthetic 400, which surfaces
as :class:`ClientError` via the standard 4xx mapping in
``RpcExecutor.raise_rpc_error_from_http_status``.
The behavior under test is the REFRESH-PATH WIRING — that
``refresh_auth`` ran exactly once before the second 400 ended the
attempt. The exact post-refresh exception type is incidental
(``ClientError`` because 400 is not 401/403, 5xx, or 429); what
matters is that the auth-refresh hook fired, observed via a spy
installed on ``client._collaborators.auth_coord._refresh_callback`` and corroborated by the
``play_count == 2`` assertion on the cassette.
"""
client = NotebookLMClient(_synthetic_auth())
# Eliminate the post-refresh retry delay so the test runs fast under
# replay (mirrors ``test_auth_refresh_vcr.py``).
client._composed.chain_host._refresh_retry_delay = 0
# In-process refresh callback that issues NO HTTP traffic. This is
# what lets the cassette capture only the TWO synthetic batchexecute
# interactions (failing POST → retried POST) without a homepage GET
# leg. The production ``refresh_auth`` re-extracts ``SNlM0e`` /
# ``FdrFJe`` from the homepage; the unit-style
# ``test_auth_refresh_vcr.py`` exercises that full three-leg flow.
refresh_calls: list[object] = []
async def stub_refresh() -> AuthTokens:
refresh_calls.append(None)
# Mutate the in-memory CSRF token to simulate a successful refresh.
# The retry loop rebuilds the request body from the refreshed
# auth snapshot after refresh, so
# this mutation is observable on the wire — and the cassette's
# request-side body would carry the refreshed value if VCR
# matched on body (it doesn't; the default matcher uses
# method/path/rpcids).
client._auth.csrf_token = "refreshed_csrf_token"
# Wave 3 of plan ``host-protocol-removal`` deleted the
# Session-level ``update_auth_headers`` forward; call the
# canonical coordinator method directly with the explicit
# collaborator kwargs.
client._collaborators.auth_coord.update_auth_headers(
auth=client._auth,
kernel=client._collaborators.kernel,
)
return client._auth
client._collaborators.auth_coord._refresh_callback = stub_refresh
with notebooklm_vcr.use_cassette("error_synthetic_stale_csrf.yaml") as cassette:
async with client:
# The first cassette interaction returns synthetic 400 →
# auth-refresh fires → second cassette interaction returns
# synthetic 400 → ClientError (4xx that isn't 401/403/429/5xx).
with pytest.raises(ClientError) as exc_info:
await client.notebooks.list()
assert exc_info.value.status_code == 400
assert exc_info.value.method_id is not None
# The auth-refresh branch fired exactly once — this is the
# load-bearing assertion of this test. Asserting equality (not >=)
# catches a regression where ``refreshed_this_call`` fails to flip and
# the refresh path runs twice for one user call.
assert len(refresh_calls) == 1, (
f"refresh_auth should run exactly once for one stale-CSRF call; "
f"got {len(refresh_calls)}"
)
# Cassette played EXACTLY two interactions: the failing POST and the
# retried POST. No homepage GET because the stub refresh callback
# avoids HTTP traffic. This shape assertion ties the test to the
# cassette's on-wire structure: if a refactor accidentally adds an
# extra round-trip (e.g. a second refresh probe) or drops the retry,
# this assertion fails immediately rather than silently passing.
assert cassette.play_count == 2, (
f"Expected exactly 2 cassette interactions to play "
f"(failing POST, retried POST); got {cassette.play_count}"
)