09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
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
623 lines
24 KiB
Python
623 lines
24 KiB
Python
"""Unit tests for :class:`ErrorInjectionMiddleware` (Tier-12 PR 12.6 / PR 12.7).
|
|
|
|
Pins the contract documented in ``src/notebooklm/_middleware/error_injection.py``
|
|
and ADR-0009 §"Chain ordering":
|
|
|
|
- **Pass-through when env var is unset.** The middleware delegates straight
|
|
to ``next_call``; production behavior is byte-for-byte unchanged.
|
|
- **Pass-through when no builder is wired (issue #1005).** Even with the
|
|
env var set to a valid mode, the production default ``builder=None``
|
|
makes ``__call__`` a pass-through. Tests that exercise substitution
|
|
must construct the middleware with an explicit ``builder=`` argument.
|
|
- **Raise transport exceptions for 429 / 5xx (PR 12.7).** When the env var
|
|
resolves to ``"429"`` AND a builder is wired, the middleware raises
|
|
:class:`TransportRateLimited` (carrying the synthetic ``Retry-After``);
|
|
``"5xx"`` raises :class:`TransportServerError`. This is the contract
|
|
that lets the OUTER ``RetryMiddleware`` retry, restoring ADR-0009
|
|
§"ErrorInjection inside Retry — synthetic transient failures trigger
|
|
retry" (codex iter-1 catch on PR 12.7).
|
|
- **Raise raw ``httpx.HTTPStatusError`` for expired_csrf (HTTP 400).** That
|
|
mode lets the OUTER ``AuthRefreshMiddleware`` catch via ``is_auth_error`` and
|
|
drive refresh-then-retry.
|
|
- **Request shape preserved on the wrapped response.** The wrapped
|
|
:class:`httpx.Response` (whether returned or carried in a raised
|
|
exception) has a ``response.request`` attached whose
|
|
``method`` / ``url`` / ``body`` mirror the incoming
|
|
:class:`RpcRequest`.
|
|
|
|
The tests use a real :class:`ErrorInjectionMiddleware` instance plus the
|
|
canonical chain fixtures (``make_request`` and a one-shot terminal stub)
|
|
rather than mocking the substitution logic. Activation is flipped via
|
|
:func:`monkeypatch.setenv` against ``NOTEBOOKLM_VCR_RECORD_ERRORS`` so the
|
|
production env-var resolution code path
|
|
(:func:`notebooklm._error_injection._get_error_injection_mode`) is
|
|
exercised end-to-end. Tests that need substitution to fire pass
|
|
``builder=build_synthetic_error_response`` from
|
|
``tests.cassette_patterns`` explicitly into the middleware constructor —
|
|
that's the post-#1005 wiring that replaces the previous filesystem-load
|
|
implementation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from notebooklm._error_injection import ERROR_INJECT_ENV_VAR
|
|
from notebooklm._middleware.core import NextCall, RpcRequest, RpcResponse, build_chain
|
|
from notebooklm._middleware.error_injection import ErrorInjectionMiddleware
|
|
from notebooklm._transport_errors import TransportRateLimited, TransportServerError
|
|
|
|
# The ``tests/`` package chain is complete; ``tests._fixtures.chain`` is the
|
|
# fully-qualified import path documented in ``tests/_fixtures/__init__.py``.
|
|
from tests._fixtures.chain import make_request
|
|
from tests.cassette_patterns import build_synthetic_error_response
|
|
|
|
|
|
def _static_terminal(response: httpx.Response) -> NextCall:
|
|
"""Build a chain-terminal coroutine that wraps ``response``."""
|
|
|
|
async def terminal(request: RpcRequest) -> RpcResponse:
|
|
return RpcResponse(response=response, context=request.context)
|
|
|
|
return terminal
|
|
|
|
|
|
def _recording_terminal() -> tuple[NextCall, list[RpcRequest]]:
|
|
"""Build a terminal that records every request it sees."""
|
|
calls: list[RpcRequest] = []
|
|
|
|
async def terminal(request: RpcRequest) -> RpcResponse:
|
|
calls.append(request)
|
|
return RpcResponse(
|
|
response=httpx.Response(status_code=200, content=b"leaf-reached"),
|
|
context=request.context,
|
|
)
|
|
|
|
return terminal, calls
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pass-through when env var is unset
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_when_env_var_unset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Default path: env var unset → middleware delegates to ``next_call``."""
|
|
monkeypatch.delenv(ERROR_INJECT_ENV_VAR, raising=False)
|
|
terminal, calls = _recording_terminal()
|
|
middleware = ErrorInjectionMiddleware()
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
response = await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
assert len(calls) == 1
|
|
assert response.response.status_code == 200
|
|
assert response.response.content == b"leaf-reached"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_when_env_var_empty(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An empty-string env var also resolves to ``None`` → pass-through."""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, " ")
|
|
terminal, calls = _recording_terminal()
|
|
middleware = ErrorInjectionMiddleware()
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
await chain(make_request())
|
|
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_when_env_var_unknown_mode(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unrecognized mode → ``_get_error_injection_mode`` returns ``None``."""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "418") # not in VALID_ERROR_MODES
|
|
terminal, calls = _recording_terminal()
|
|
middleware = ErrorInjectionMiddleware()
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
await chain(make_request())
|
|
|
|
assert len(calls) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 429 → raise TransportRateLimited
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_429_mode_raises_transport_rate_limited(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""``429`` mode raises :class:`TransportRateLimited` for ``RetryMiddleware``.
|
|
|
|
Restores ADR-0009 §"ErrorInjection inside Retry — synthetic transient
|
|
failures trigger retry" (codex iter-1 catch on PR 12.7). The raised
|
|
exception carries the synthetic ``Retry-After`` so the outer retry
|
|
honors rate-limit timing.
|
|
"""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "429")
|
|
terminal, calls = _recording_terminal()
|
|
middleware = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(TransportRateLimited) as excinfo:
|
|
await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
assert calls == []
|
|
exc = excinfo.value
|
|
assert exc.retry_after == 1
|
|
assert exc.response is not None
|
|
assert exc.response.status_code == 429
|
|
assert "RPC LIST_NOTEBOOKS" in str(exc)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_429_response_carries_synthetic_request_url_and_body(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The synthetic ``httpx.Response.request`` mirrors the chain request."""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "429")
|
|
middleware = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain([middleware], _static_terminal(httpx.Response(200, content=b"unreached")))
|
|
|
|
custom_url = "https://example.test/_/LabsTailwindUi/data/batchexecute?authuser=0"
|
|
with pytest.raises(TransportRateLimited) as excinfo:
|
|
await chain(make_request(url=custom_url, body=b"chain-body"))
|
|
|
|
response = excinfo.value.response
|
|
assert response is not None
|
|
assert response.request is not None
|
|
assert response.request.method == "POST"
|
|
assert str(response.request.url) == custom_url
|
|
assert response.request.content == b"chain-body"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5xx → raise TransportServerError
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_5xx_mode_raises_transport_server_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""``5xx`` mode raises :class:`TransportServerError` for ``RetryMiddleware``."""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "5xx")
|
|
terminal, calls = _recording_terminal()
|
|
middleware = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(TransportServerError) as excinfo:
|
|
await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
assert calls == []
|
|
exc = excinfo.value
|
|
assert exc.status_code == 500
|
|
assert exc.response is not None
|
|
assert "application/json" in exc.response.headers.get("content-type", "")
|
|
assert b'"error"' in exc.response.content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# expired_csrf → raise httpx.HTTPStatusError (AuthRefreshMiddleware catches)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expired_csrf_mode_raises_http_status_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""``expired_csrf`` mode raises the raw ``httpx.HTTPStatusError``.
|
|
|
|
PR 12.8 wired AuthRefreshMiddleware outside this middleware in the
|
|
final chain ordering. AuthRefresh catches via ``is_auth_error``
|
|
(which recognizes 400/401/403 from Google's auth-shape responses)
|
|
and drives the refresh-then-retry flow. Pre-PR-12.6 this happened
|
|
naturally because the legacy ``_SyntheticErrorTransport`` returned
|
|
the synthetic 400 below httpx and the leaf's auth-refresh branch
|
|
handled it.
|
|
"""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "expired_csrf")
|
|
terminal, calls = _recording_terminal()
|
|
middleware = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
|
await chain(make_request())
|
|
|
|
assert calls == [] # leaf NOT reached
|
|
assert excinfo.value.response.status_code == 400
|
|
assert "HTTP 400" in str(excinfo.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expired_csrf_response_carries_synthetic_request_url(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The raised HTTPStatusError wraps a synthetic ``httpx.Response`` whose
|
|
``.request`` mirrors the chain envelope."""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "expired_csrf")
|
|
middleware = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain([middleware], _static_terminal(httpx.Response(200, content=b"unreached")))
|
|
|
|
custom_url = "https://example.test/_/LabsTailwindUi/data/batchexecute?authuser=0"
|
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
|
await chain(make_request(url=custom_url))
|
|
|
|
response = excinfo.value.response
|
|
assert response.status_code == 400
|
|
assert response.request is not None
|
|
assert str(response.request.url) == custom_url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: Retry + ErrorInjection actually retries (codex iter-1 finding)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_outside_error_injection_retries_synthetic_429(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""End-to-end: chain ``[Retry, ErrorInjection]`` retries synthetic 429s.
|
|
|
|
This is the codex iter-1 catch: without ErrorInjection raising
|
|
:class:`TransportRateLimited`, the synthetic 429 flowed back as a
|
|
returned response and Retry never saw it. With the exception-raising
|
|
fix, Retry catches and retries N times before re-raising.
|
|
"""
|
|
from notebooklm._middleware.retry import RetryMiddleware
|
|
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "429")
|
|
slept: list[float] = []
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
slept.append(seconds)
|
|
|
|
error_injection = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
retry = RetryMiddleware(
|
|
rate_limit_max_retries=2,
|
|
server_error_max_retries=2,
|
|
sleep=fake_sleep,
|
|
)
|
|
chain = build_chain(
|
|
[retry, error_injection], _static_terminal(httpx.Response(200, content=b"x"))
|
|
)
|
|
|
|
with pytest.raises(TransportRateLimited):
|
|
await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
# 1 initial + 2 retries → 2 sleeps observed.
|
|
assert len(slept) == 2
|
|
# Each sleep honors the synthetic Retry-After=1.
|
|
assert slept == [1.0, 1.0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_outside_error_injection_retries_synthetic_5xx(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""End-to-end: chain ``[Retry, ErrorInjection]`` retries synthetic 5xx too."""
|
|
from notebooklm._middleware.retry import RetryMiddleware
|
|
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "5xx")
|
|
slept: list[float] = []
|
|
|
|
async def fake_sleep(seconds: float) -> None:
|
|
slept.append(seconds)
|
|
|
|
error_injection = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
retry = RetryMiddleware(
|
|
rate_limit_max_retries=2,
|
|
server_error_max_retries=1,
|
|
sleep=fake_sleep,
|
|
)
|
|
chain = build_chain(
|
|
[retry, error_injection], _static_terminal(httpx.Response(200, content=b"x"))
|
|
)
|
|
|
|
with pytest.raises(TransportServerError):
|
|
await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
# 1 initial + 1 retry → 1 sleep observed.
|
|
assert len(slept) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: AuthRefresh + ErrorInjection drives refresh on synthetic 400
|
|
# (codex iter-1 finding on PR 12.8 — locks in the regression fix)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auth_refresh_outside_error_injection_triggers_refresh_on_expired_csrf(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""End-to-end: chain ``[AuthRefresh, ErrorInjection]`` refreshes on synthetic 400.
|
|
|
|
Codex iter-1 catch on PR 12.8: PR 12.6 broke the refresh-on-synthetic-400
|
|
path by returning an ``RpcResponse`` from :class:`ErrorInjectionMiddleware`
|
|
for ``expired_csrf`` mode. PR 12.8 fixes by raising raw
|
|
``httpx.HTTPStatusError`` so :class:`AuthRefreshMiddleware` outside it
|
|
catches via ``is_auth_error`` and drives refresh-then-retry.
|
|
|
|
This is the missing E2E counterpart to the ``[Retry, ErrorInjection]``
|
|
pair above — without it the integration is only validated by two
|
|
independent unit tests (the leaf raises 400; AuthRefresh catches 400)
|
|
but never end-to-end on a real two-middleware chain.
|
|
|
|
Test shape: env var stays on across the retry leg, so the retry leg
|
|
also raises ``HTTPStatusError(400)``. The exactly-once contract from
|
|
ADR-0009 §"Retry semantics" means refresh runs exactly once and the
|
|
second 400 propagates without recursion.
|
|
"""
|
|
from notebooklm._middleware.auth_refresh import AuthRefreshMiddleware
|
|
from notebooklm._runtime.helpers import is_auth_error as auth_error_predicate
|
|
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "expired_csrf")
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
refresh_calls.append(None)
|
|
|
|
auth_refresh = AuthRefreshMiddleware(
|
|
refresh_callable=refresh,
|
|
is_auth_error=auth_error_predicate,
|
|
refresh_callback_enabled=lambda: True,
|
|
refresh_retry_delay=lambda: 0.0,
|
|
)
|
|
error_injection = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain(
|
|
[auth_refresh, error_injection],
|
|
_static_terminal(httpx.Response(200, content=b"unreached-because-env-is-on")),
|
|
)
|
|
|
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
|
await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
# AuthRefresh caught the synthetic 400 from ErrorInjection and drove
|
|
# ONE refresh — the retry leg's 400 propagates unchanged (exactly-once
|
|
# contract). Without PR 12.8's fix, ErrorInjection would have RETURNED
|
|
# a 400 RpcResponse, AuthRefresh would have seen no exception, refresh
|
|
# would never have fired, and ``refresh_calls`` would be empty.
|
|
assert len(refresh_calls) == 1
|
|
assert excinfo.value.response.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auth_refresh_outside_error_injection_completes_when_env_flips_off(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""End-to-end happy path: ``[AuthRefresh, ErrorInjection]`` retries successfully.
|
|
|
|
Companion to the test above: when the env var is flipped off during
|
|
refresh (production analogue: a real ``__Secure-1PSIDTS`` rotation
|
|
succeeded and the retry no longer hits the synthetic 400 path), the
|
|
chain returns 200 cleanly. This pins the full refresh-then-retry
|
|
success path, not just the propagation path.
|
|
"""
|
|
from notebooklm._middleware.auth_refresh import AuthRefreshMiddleware
|
|
from notebooklm._runtime.helpers import is_auth_error as auth_error_predicate
|
|
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "expired_csrf")
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
# Simulate a successful token rotation that disarms the injector
|
|
# before the retry leg runs.
|
|
refresh_calls.append(None)
|
|
monkeypatch.delenv(ERROR_INJECT_ENV_VAR, raising=False)
|
|
|
|
auth_refresh = AuthRefreshMiddleware(
|
|
refresh_callable=refresh,
|
|
is_auth_error=auth_error_predicate,
|
|
refresh_callback_enabled=lambda: True,
|
|
refresh_retry_delay=lambda: 0.0,
|
|
)
|
|
error_injection = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain(
|
|
[auth_refresh, error_injection],
|
|
_static_terminal(httpx.Response(200, content=b"after-refresh-success")),
|
|
)
|
|
|
|
response = await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
assert len(refresh_calls) == 1
|
|
assert response.response.status_code == 200
|
|
assert response.response.content == b"after-refresh-success"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Builder injection (issue #1005)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_constructor_has_no_builder_and_passes_through(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Default ``ErrorInjectionMiddleware()`` (no builder arg) is a pass-through.
|
|
|
|
Issue #1005 hardening: even with the env var set to a valid mode, a
|
|
middleware constructed without an injected builder must NOT short-circuit.
|
|
Production code (``MiddlewareChainBuilder``) constructs the middleware
|
|
this way, so this test pins the "leaked env var on a user install can
|
|
never trigger synthetic substitution" invariant.
|
|
"""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "429")
|
|
terminal, calls = _recording_terminal()
|
|
middleware = ErrorInjectionMiddleware() # no builder → pass-through
|
|
|
|
assert middleware._builder is None
|
|
chain = build_chain([middleware], terminal)
|
|
response = await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
# Leaf was reached — middleware did NOT raise / short-circuit.
|
|
assert len(calls) == 1
|
|
assert response.response.status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_builder_is_used_on_every_call(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The injected ``builder`` callable is invoked on every chain call.
|
|
|
|
Replaces the legacy ``_load_builder`` caching test: now the builder is
|
|
supplied at construction time so there's no lazy-load to cache. We
|
|
verify the SAME injected callable is what produces the synthetic
|
|
response on each call by counting invocations.
|
|
"""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "429")
|
|
invocations: list[str] = []
|
|
|
|
def counting_builder(mode: str) -> tuple[int, bytes, dict[str, str]]:
|
|
invocations.append(mode)
|
|
return build_synthetic_error_response(mode)
|
|
|
|
middleware = ErrorInjectionMiddleware(builder=counting_builder)
|
|
chain = build_chain([middleware], _static_terminal(httpx.Response(200, content=b"unreached")))
|
|
|
|
with pytest.raises(TransportRateLimited):
|
|
await chain(make_request())
|
|
with pytest.raises(TransportRateLimited):
|
|
await chain(make_request())
|
|
|
|
# Injected builder was invoked on each chain call (the synthetic
|
|
# response object is built fresh per call — there's no instance cache
|
|
# of the response itself, only the builder is held).
|
|
assert invocations == ["429", "429"]
|
|
assert middleware._builder is counting_builder
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Activation flip mid-chain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_activation_flip_between_calls_is_observed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Flipping the env var between two chain calls observes both modes."""
|
|
monkeypatch.delenv(ERROR_INJECT_ENV_VAR, raising=False)
|
|
terminal, calls = _recording_terminal()
|
|
middleware = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
# Call 1: pass-through, leaf reached.
|
|
await chain(make_request())
|
|
assert len(calls) == 1
|
|
|
|
# Flip on.
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "5xx")
|
|
|
|
# Call 2: short-circuit (now raises), leaf NOT reached again.
|
|
with pytest.raises(TransportServerError):
|
|
await chain(make_request())
|
|
assert len(calls) == 1 # still 1 — leaf was bypassed on second call
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Type hygiene
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_middleware_satisfies_protocol() -> None:
|
|
"""``ErrorInjectionMiddleware`` instance is assignable to ``Middleware``."""
|
|
from notebooklm._middleware.core import Middleware
|
|
|
|
middleware: Middleware = ErrorInjectionMiddleware()
|
|
assert callable(middleware)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Monkeypatch seam + activation log
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_monkeypatch_setattr_on_get_error_injection_mode_is_live(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The middleware resolves ``_get_error_injection_mode`` through the
|
|
module at call time, so ``monkeypatch.setattr(_error_injection,
|
|
"_get_error_injection_mode", …)`` reaches the chain.
|
|
"""
|
|
from notebooklm import _error_injection as _eim_module
|
|
|
|
monkeypatch.delenv(ERROR_INJECT_ENV_VAR, raising=False)
|
|
monkeypatch.setattr(_eim_module, "_get_error_injection_mode", lambda: "5xx")
|
|
|
|
terminal, calls = _recording_terminal()
|
|
middleware = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(TransportServerError):
|
|
await chain(make_request())
|
|
|
|
assert calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_activation_log_fires_once_per_instance(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The "synthetic-error injection enabled" log line fires exactly once."""
|
|
monkeypatch.setenv(ERROR_INJECT_ENV_VAR, "5xx")
|
|
middleware = ErrorInjectionMiddleware(builder=build_synthetic_error_response)
|
|
chain = build_chain([middleware], _static_terminal(httpx.Response(200, content=b"x")))
|
|
|
|
with caplog.at_level("INFO", logger="notebooklm._core"):
|
|
# Each call raises; this verifies the log is emitted on the FIRST
|
|
# call's path and suppressed on subsequent calls.
|
|
with pytest.raises(TransportServerError):
|
|
await chain(make_request())
|
|
with pytest.raises(TransportServerError):
|
|
await chain(make_request())
|
|
with pytest.raises(TransportServerError):
|
|
await chain(make_request())
|
|
|
|
activations = [r for r in caplog.records if "synthetic-error injection enabled" in r.message]
|
|
assert len(activations) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Function-call signature
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_receives_next_call_and_invokes_it(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When the env var is unset, ``__call__`` invokes ``next_call(request)``."""
|
|
monkeypatch.delenv(ERROR_INJECT_ENV_VAR, raising=False)
|
|
seen: list[RpcRequest] = []
|
|
|
|
async def next_call(request: RpcRequest) -> RpcResponse:
|
|
seen.append(request)
|
|
return RpcResponse(
|
|
response=httpx.Response(status_code=200, content=b"next-called"),
|
|
context=request.context,
|
|
)
|
|
|
|
middleware = ErrorInjectionMiddleware()
|
|
request = make_request()
|
|
|
|
response = await middleware(request, next_call)
|
|
|
|
assert seen == [request]
|
|
assert response.response.content == b"next-called"
|