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
744 lines
26 KiB
Python
744 lines
26 KiB
Python
"""Unit tests for :class:`AuthRefreshMiddleware` (Tier-12 PR 12.8).
|
|
|
|
Pins the contract documented in ``src/notebooklm/_middleware/auth_refresh.py``
|
|
and ADR-0009 §"Chain ordering":
|
|
|
|
- **Pass-through on success.** Single ``next_call``; result returned.
|
|
- **Pass-through on non-auth exception.** ``TransportRateLimited`` /
|
|
``TransportServerError`` propagate to ``RetryMiddleware`` outside.
|
|
- **Pass-through when no refresh callback configured.** The
|
|
``refresh_callback_enabled`` gate matches the legacy
|
|
``host._refresh_callback is not None`` check.
|
|
- **Pass-through when ``disable_internal_retries`` is set.** A
|
|
non-idempotent / probe-then-create method (effective disable bool set
|
|
on the context) is NOT replayed after an auth error — the write may
|
|
have already committed before the 401/403 surfaced (issue #1157).
|
|
- **Refresh-and-retry on auth error.** First ``next_call`` raises
|
|
``httpx.HTTPStatusError`` recognized by ``is_auth_error`` → refresh
|
|
callable runs (coalesced single-flight) → optional post-refresh sleep
|
|
→ metric increment → rebuilt request envelope when a snapshot provider is
|
|
wired → exactly one retry via ``next_call(retry_request)``.
|
|
- **Refresh failure** → wrap original ``HTTPStatusError`` in
|
|
``TransportAuthExpired`` and propagate.
|
|
- **Exactly one retry.** Per ADR-0009 §"Retry semantics", a second auth
|
|
error on the retry leg propagates — no second refresh, no recursion.
|
|
- **Post-refresh sleep honored** when ``refresh_retry_delay > 0``.
|
|
- **Live-bound `refresh_retry_delay`.** Callable getter so test mutation
|
|
on the host still takes effect (matches the RetryMiddleware idiom).
|
|
- **Log shape preservation** — "auth error detected, attempting token
|
|
refresh" / "Token refresh failed: X" / "Token refresh successful,
|
|
retrying Y" match the legacy transport messages bit-for-bit
|
|
so log-grep alerts keep matching.
|
|
- **Metrics increment** — ``rpc_auth_retries`` incremented exactly once
|
|
per successful refresh.
|
|
- **Log-label fallback** — missing ``log_label`` in context surfaces
|
|
``"<unknown-chain-call>"`` rather than ``KeyError`` (defensive for
|
|
``__new__``-built fixtures; matches Drain/Retry behavior).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from notebooklm._client_metrics import ClientMetrics
|
|
from notebooklm._middleware.auth_refresh import AuthRefreshMiddleware
|
|
from notebooklm._middleware.context import RPC_CONTEXT_RETRY_DEADLINE
|
|
from notebooklm._middleware.core import NextCall, RpcRequest, RpcResponse, build_chain
|
|
from notebooklm._request_types import AuthSnapshot
|
|
from notebooklm._runtime.helpers import is_auth_error
|
|
from notebooklm._transport_errors import (
|
|
TransportAuthExpired,
|
|
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
|
|
|
|
|
|
def _recording_sleep() -> tuple[Callable[[float], Awaitable[None]], list[float]]:
|
|
slept: list[float] = []
|
|
|
|
async def sleep(seconds: float) -> None:
|
|
slept.append(seconds)
|
|
|
|
return sleep, slept
|
|
|
|
|
|
def _auth_error(*, status: int = 401, url: str = "https://example.test/x") -> httpx.HTTPStatusError:
|
|
"""Build an ``httpx.HTTPStatusError`` that ``is_auth_error`` recognizes."""
|
|
request = httpx.Request("POST", url)
|
|
response = httpx.Response(status, request=request)
|
|
return httpx.HTTPStatusError(f"HTTP {status}", request=request, response=response)
|
|
|
|
|
|
def _scripted_terminal(behaviors: list[Any]) -> tuple[NextCall, list[RpcRequest]]:
|
|
"""Yield each ``behaviors`` entry per call. Exceptions raise; Responses wrap."""
|
|
calls: list[RpcRequest] = []
|
|
iterator = iter(behaviors)
|
|
|
|
async def terminal(request: RpcRequest) -> RpcResponse:
|
|
calls.append(request)
|
|
nxt = next(iterator)
|
|
if isinstance(nxt, BaseException):
|
|
raise nxt
|
|
return RpcResponse(response=nxt, context=request.context)
|
|
|
|
return terminal, calls
|
|
|
|
|
|
def _make_middleware(
|
|
*,
|
|
refresh_callable: Callable[[], Awaitable[None]] | None = None,
|
|
refresh_enabled: bool = True,
|
|
refresh_retry_delay: float = 0.0,
|
|
sleep: Callable[[float], Awaitable[object]] | None = None,
|
|
metrics: ClientMetrics | None = None,
|
|
snapshot_provider: Callable[[], Awaitable[AuthSnapshot]] | None = None,
|
|
auth_error_predicate: Callable[[Exception], bool] = is_auth_error,
|
|
) -> AuthRefreshMiddleware:
|
|
"""Build an ``AuthRefreshMiddleware`` with sensible defaults for tests."""
|
|
|
|
async def _noop_refresh() -> None:
|
|
return None
|
|
|
|
return AuthRefreshMiddleware(
|
|
refresh_callable=refresh_callable or _noop_refresh,
|
|
is_auth_error=auth_error_predicate,
|
|
refresh_callback_enabled=lambda: refresh_enabled,
|
|
refresh_retry_delay=lambda: refresh_retry_delay,
|
|
snapshot_provider=snapshot_provider,
|
|
sleep=sleep,
|
|
metrics=metrics,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pass-through paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_on_success() -> None:
|
|
"""No exception → single ``next_call`` invocation, response returned."""
|
|
terminal, calls = _scripted_terminal([httpx.Response(200, content=b"ok")])
|
|
middleware = _make_middleware()
|
|
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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_on_rate_limited() -> None:
|
|
"""``TransportRateLimited`` propagates to ``RetryMiddleware`` outside."""
|
|
request = httpx.Request("POST", "https://example.test/x")
|
|
boom = TransportRateLimited(
|
|
"rate limited",
|
|
retry_after=1,
|
|
response=httpx.Response(429, request=request),
|
|
original=httpx.HTTPStatusError("HTTP 429", request=request, response=httpx.Response(429)),
|
|
)
|
|
terminal, calls = _scripted_terminal([boom])
|
|
middleware = _make_middleware()
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(TransportRateLimited) as excinfo:
|
|
await chain(make_request())
|
|
|
|
assert excinfo.value is boom
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_on_server_error() -> None:
|
|
"""``TransportServerError`` propagates."""
|
|
request = httpx.Request("POST", "https://example.test/x")
|
|
boom = TransportServerError(
|
|
"server error",
|
|
original=httpx.HTTPStatusError("HTTP 503", request=request, response=httpx.Response(503)),
|
|
)
|
|
terminal, calls = _scripted_terminal([boom])
|
|
middleware = _make_middleware()
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(TransportServerError) as excinfo:
|
|
await chain(make_request())
|
|
|
|
assert excinfo.value is boom
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_when_refresh_callback_not_configured() -> None:
|
|
"""No refresh callback → propagate auth error without refresh attempt.
|
|
|
|
Matches the legacy ``host._refresh_callback is not None`` gate in the
|
|
leaf — a client constructed without ``refresh_callback`` should not
|
|
silently swallow auth errors.
|
|
"""
|
|
boom = _auth_error(status=401)
|
|
terminal, calls = _scripted_terminal([boom])
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
refresh_calls.append(None)
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh, refresh_enabled=False)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
|
await chain(make_request())
|
|
|
|
assert excinfo.value is boom
|
|
assert len(calls) == 1
|
|
assert refresh_calls == [] # refresh NOT triggered
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_on_non_auth_http_error() -> None:
|
|
"""A non-auth HTTPStatusError (e.g. 404) propagates without refresh."""
|
|
boom = _auth_error(status=404) # 404 is NOT 400/401/403
|
|
terminal, calls = _scripted_terminal([boom])
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
refresh_calls.append(None)
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
|
await chain(make_request())
|
|
|
|
assert excinfo.value is boom
|
|
assert refresh_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_through_when_disable_internal_retries_set() -> None:
|
|
"""``disable_internal_retries`` on the context suppresses the auth replay.
|
|
|
|
Regression for issue #1157: a non-idempotent / probe-then-create method
|
|
arrives with the post-resolution effective disable bool set on its
|
|
context. A 401/403 can land *after* the server committed the write, so
|
|
re-POSTing would duplicate the resource / invite / generation. The
|
|
middleware must propagate the original auth error instead of refreshing
|
|
and retrying.
|
|
"""
|
|
boom = _auth_error(status=401)
|
|
terminal, calls = _scripted_terminal([boom])
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
refresh_calls.append(None)
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
|
await chain(
|
|
make_request(
|
|
context={
|
|
"log_label": "RPC CREATE_NOTEBOOK",
|
|
"disable_internal_retries": True,
|
|
}
|
|
)
|
|
)
|
|
|
|
assert excinfo.value is boom
|
|
assert len(calls) == 1 # initial attempt only — no replay
|
|
assert refresh_calls == [] # refresh NOT triggered
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refreshes_when_disable_internal_retries_falsy() -> None:
|
|
"""An explicit falsy ``disable_internal_retries`` still allows the replay.
|
|
|
|
Guards the gate against over-triggering: a retry-safe method whose
|
|
effective disable flag is False must keep the refresh-and-retry path.
|
|
"""
|
|
boom = _auth_error(status=401)
|
|
terminal, calls = _scripted_terminal([boom, httpx.Response(200, content=b"retry-ok")])
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
refresh_calls.append(None)
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
response = await chain(
|
|
make_request(
|
|
context={
|
|
"log_label": "RPC LIST_NOTEBOOKS",
|
|
"disable_internal_retries": False,
|
|
}
|
|
)
|
|
)
|
|
|
|
assert refresh_calls == [None]
|
|
assert len(calls) == 2
|
|
assert response.response.status_code == 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Refresh-and-retry path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refreshes_and_retries_on_auth_error() -> None:
|
|
"""401 → refresh → retry → success."""
|
|
boom = _auth_error(status=401)
|
|
terminal, calls = _scripted_terminal([boom, httpx.Response(200, content=b"retry-ok")])
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
refresh_calls.append(None)
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
response = await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
assert refresh_calls == [None]
|
|
assert len(calls) == 2 # initial + retry
|
|
assert response.response.status_code == 200
|
|
assert response.response.content == b"retry-ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_rebuilds_request_envelope_from_fresh_snapshot() -> None:
|
|
"""Post-refresh retry replaces URL, headers, and body before terminal send."""
|
|
boom = _auth_error(status=401)
|
|
terminal, calls = _scripted_terminal([boom, httpx.Response(200, content=b"retry-ok")])
|
|
fresh_snapshot = AuthSnapshot(
|
|
csrf_token="CSRF_NEW",
|
|
session_id="SID_NEW",
|
|
authuser=1,
|
|
account_email=None,
|
|
)
|
|
build_snapshots: list[AuthSnapshot] = []
|
|
|
|
async def snapshot_provider() -> AuthSnapshot:
|
|
return fresh_snapshot
|
|
|
|
def build_request(snapshot: AuthSnapshot) -> tuple[str, str, dict[str, str]]:
|
|
build_snapshots.append(snapshot)
|
|
return (
|
|
f"https://example.test/x?sid={snapshot.session_id}",
|
|
f"body-{snapshot.csrf_token}",
|
|
{"X-Goog-AuthUser": str(snapshot.authuser)},
|
|
)
|
|
|
|
middleware = _make_middleware(snapshot_provider=snapshot_provider)
|
|
chain = build_chain([middleware], terminal)
|
|
request = make_request(
|
|
url="https://example.test/x?sid=SID_OLD",
|
|
headers={"X-Goog-AuthUser": "0"},
|
|
body=b"body-CSRF_OLD",
|
|
context={"log_label": "RPC LIST_NOTEBOOKS", "build_request": build_request},
|
|
)
|
|
|
|
response = await chain(request)
|
|
|
|
assert response.response.status_code == 200
|
|
assert len(calls) == 2
|
|
assert calls[0] is request
|
|
retry_request = calls[1]
|
|
assert retry_request is not request
|
|
assert retry_request.url == "https://example.test/x?sid=SID_NEW"
|
|
assert retry_request.headers == {"X-Goog-AuthUser": "1"}
|
|
assert retry_request.body == b"body-CSRF_NEW"
|
|
assert retry_request.context is request.context
|
|
assert request.context["auth_snapshot"] == fresh_snapshot
|
|
assert build_snapshots == [fresh_snapshot]
|
|
|
|
|
|
@pytest.mark.parametrize("status", [400, 401, 403])
|
|
@pytest.mark.asyncio
|
|
async def test_refreshes_on_all_auth_status_codes(status: int) -> None:
|
|
"""Google returns 400 / 401 / 403 for auth errors — all trigger refresh."""
|
|
boom = _auth_error(status=status)
|
|
terminal, calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")])
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
refresh_calls.append(None)
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
response = await chain(make_request())
|
|
|
|
assert refresh_calls == [None]
|
|
assert len(calls) == 2
|
|
assert response.response.status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_retry_delay_honored() -> None:
|
|
"""``refresh_retry_delay > 0`` → sleep that duration before retry."""
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")])
|
|
sleep, slept = _recording_sleep()
|
|
|
|
middleware = _make_middleware(refresh_retry_delay=1.5, sleep=sleep)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
await chain(make_request())
|
|
|
|
assert slept == [1.5]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_retry_delay_zero_skips_sleep() -> None:
|
|
"""``refresh_retry_delay == 0`` → no sleep call."""
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")])
|
|
sleep, slept = _recording_sleep()
|
|
|
|
middleware = _make_middleware(refresh_retry_delay=0.0, sleep=sleep)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
await chain(make_request())
|
|
|
|
assert slept == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_retry_delay_clamped_by_inherited_deadline() -> None:
|
|
"""Issue #1873: the wire-401 post-refresh sleep is clamped to the aggregate.
|
|
|
|
When the RPC executor seeds ``RPC_CONTEXT_RETRY_DEADLINE``, the auth-refresh
|
|
layer must clamp its post-refresh sleep to the time remaining since T0 —
|
|
otherwise a ``refresh_retry_delay`` larger than the remaining budget would
|
|
re-POST past the logical call's deadline (mirroring the decode-time path).
|
|
"""
|
|
from notebooklm._deadline import RuntimeDeadline
|
|
|
|
clock = [1000.0]
|
|
|
|
def monotonic() -> float:
|
|
return clock[0]
|
|
|
|
# 10s aggregate budget; advance the clock so only 2s remain when the
|
|
# post-refresh sleep is computed.
|
|
deadline = RuntimeDeadline.start(10.0, monotonic=monotonic)
|
|
clock[0] = 1008.0
|
|
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")])
|
|
sleep, slept = _recording_sleep()
|
|
|
|
# refresh_retry_delay (100s) far exceeds the 2s remaining budget.
|
|
middleware = _make_middleware(refresh_retry_delay=100.0, sleep=sleep)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
await chain(make_request(context={RPC_CONTEXT_RETRY_DEADLINE: deadline}))
|
|
|
|
# Clamped to the remaining 2s, not the full 100s.
|
|
assert slept == [2.0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Refresh failure → TransportAuthExpired
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_failure_raises_transport_auth_expired() -> None:
|
|
"""Refresh callable raises → wrap in ``TransportAuthExpired``."""
|
|
boom = _auth_error(status=401)
|
|
terminal, calls = _scripted_terminal([boom]) # only one attempt — refresh fails before retry
|
|
refresh_error = RuntimeError("refresh blew up")
|
|
|
|
async def refresh() -> None:
|
|
raise refresh_error
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(TransportAuthExpired) as excinfo:
|
|
await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
assert excinfo.value.original is boom
|
|
assert excinfo.value.__cause__ is refresh_error
|
|
assert "RPC LIST_NOTEBOOKS" in str(excinfo.value)
|
|
assert len(calls) == 1 # NO retry attempted
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Once-per-logical-call contract (codex iter-1 catch on PR 12.8)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auth_refresh_skipped_when_context_marks_already_refreshed() -> None:
|
|
"""If ``context["auth_refreshed"]`` is set, a fresh 401 propagates.
|
|
|
|
Models the load-bearing scenario where ``RetryMiddleware`` (outside
|
|
this middleware in the final chain) re-invokes the chain after a 429
|
|
that fired post-refresh. The retry hits a 401 again; without the
|
|
per-request flag, AuthRefreshMiddleware would refresh a SECOND time.
|
|
With it, the 401 propagates — restoring the pre-PR-12.7
|
|
"one refresh max per logical call" contract.
|
|
|
|
Codex iter-1 PR 12.8 finding.
|
|
"""
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom])
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
refresh_calls.append(None)
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
# Simulate a chain re-entry where a prior leg already refreshed.
|
|
request = make_request(context={"auth_refreshed": True})
|
|
|
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
|
await chain(request)
|
|
|
|
assert excinfo.value is boom
|
|
assert refresh_calls == [] # NO refresh — prior leg already did it
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_auth_refreshed_flag_set_after_first_refresh() -> None:
|
|
"""A successful refresh marks ``context["auth_refreshed"]`` so a later
|
|
chain re-entry through ``RetryMiddleware`` won't refresh again.
|
|
"""
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")])
|
|
|
|
async def refresh() -> None:
|
|
return None
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
request = make_request(context={"log_label": "RPC LIST_NOTEBOOKS"})
|
|
await chain(request)
|
|
|
|
assert request.context["auth_refreshed"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exactly-one-retry contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_second_auth_error_on_retry_propagates_no_recursion() -> None:
|
|
"""Per ADR-0009: "exactly one retry per next_call invocation".
|
|
|
|
First call raises 401 → refresh → retry raises 401 again → propagate.
|
|
The middleware must NOT refresh a second time, NOT recurse.
|
|
"""
|
|
first_boom = _auth_error(status=401)
|
|
second_boom = _auth_error(status=401)
|
|
terminal, calls = _scripted_terminal([first_boom, second_boom])
|
|
refresh_calls: list[None] = []
|
|
|
|
async def refresh() -> None:
|
|
refresh_calls.append(None)
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
|
await chain(make_request())
|
|
|
|
assert excinfo.value is second_boom
|
|
assert len(refresh_calls) == 1 # exactly one refresh
|
|
assert len(calls) == 2 # initial + one retry
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Metrics emission
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metrics_increment_on_successful_refresh() -> None:
|
|
"""``rpc_auth_retries`` increments once per successful refresh."""
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")])
|
|
metrics = ClientMetrics()
|
|
|
|
async def refresh() -> None:
|
|
return None
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh, metrics=metrics)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
await chain(make_request())
|
|
|
|
assert metrics.snapshot().rpc_auth_retries == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metrics_not_incremented_on_refresh_failure() -> None:
|
|
"""Refresh failure → no auth-retry metric (no retry happened)."""
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom])
|
|
metrics = ClientMetrics()
|
|
|
|
async def refresh() -> None:
|
|
raise RuntimeError("refresh failed")
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh, metrics=metrics)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with pytest.raises(TransportAuthExpired):
|
|
await chain(make_request())
|
|
|
|
assert metrics.snapshot().rpc_auth_retries == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Log shape preservation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_log_shape_on_successful_refresh(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""Log messages match the legacy transport shape verbatim."""
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")])
|
|
|
|
async def refresh() -> None:
|
|
return None
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with caplog.at_level("INFO", logger="notebooklm._core"):
|
|
await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}))
|
|
|
|
info_msgs = [r.message for r in caplog.records if r.levelname == "INFO"]
|
|
assert any(
|
|
"RPC LIST_NOTEBOOKS auth error detected, attempting token refresh" in m for m in info_msgs
|
|
)
|
|
assert any("Token refresh successful, retrying RPC LIST_NOTEBOOKS" in m for m in info_msgs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_log_shape_on_refresh_failure(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""Refresh failure log matches "Token refresh failed: X"."""
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom])
|
|
|
|
async def refresh() -> None:
|
|
raise RuntimeError("login expired")
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with (
|
|
caplog.at_level("WARNING", logger="notebooklm._core"),
|
|
pytest.raises(TransportAuthExpired),
|
|
):
|
|
await chain(make_request())
|
|
|
|
warn_msgs = [r.message for r in caplog.records if r.levelname == "WARNING"]
|
|
assert any("Token refresh failed: login expired" in m for m in warn_msgs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Log-label fallback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_log_label_falls_back_to_sentinel(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A request without ``log_label`` does not raise ``KeyError``."""
|
|
boom = _auth_error()
|
|
terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")])
|
|
|
|
async def refresh() -> None:
|
|
return None
|
|
|
|
middleware = _make_middleware(refresh_callable=refresh)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
with caplog.at_level("INFO", logger="notebooklm._core"):
|
|
await chain(make_request(context={})) # no log_label
|
|
|
|
info_msgs = [r.message for r in caplog.records if r.levelname == "INFO"]
|
|
assert any("<unknown-chain-call>" in m for m in info_msgs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Live-bound refresh_retry_delay
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_retry_delay_is_live_bound() -> None:
|
|
"""Mutating the delay between chain calls takes effect on the next call."""
|
|
delay = [0.0]
|
|
|
|
async def refresh() -> None:
|
|
return None
|
|
|
|
sleep, slept = _recording_sleep()
|
|
middleware = AuthRefreshMiddleware(
|
|
refresh_callable=refresh,
|
|
is_auth_error=is_auth_error,
|
|
refresh_callback_enabled=lambda: True,
|
|
refresh_retry_delay=lambda: delay[0],
|
|
sleep=sleep,
|
|
)
|
|
boom1 = _auth_error()
|
|
boom2 = _auth_error()
|
|
terminal, _calls = _scripted_terminal(
|
|
[boom1, httpx.Response(200, content=b"ok"), boom2, httpx.Response(200, content=b"ok")]
|
|
)
|
|
chain = build_chain([middleware], terminal)
|
|
|
|
# Call 1: delay=0 → no sleep.
|
|
await chain(make_request())
|
|
assert slept == []
|
|
|
|
# Mutate the delay.
|
|
delay[0] = 0.5
|
|
|
|
# Call 2: delay=0.5 → one sleep of 0.5.
|
|
await chain(make_request())
|
|
assert slept == [0.5]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Type hygiene
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_middleware_satisfies_protocol() -> None:
|
|
"""``AuthRefreshMiddleware`` instance is assignable to ``Middleware``."""
|
|
from notebooklm._middleware.core import Middleware
|
|
|
|
async def _noop() -> None:
|
|
return None
|
|
|
|
middleware: Middleware = AuthRefreshMiddleware(
|
|
refresh_callable=_noop,
|
|
is_auth_error=is_auth_error,
|
|
refresh_callback_enabled=lambda: True,
|
|
refresh_retry_delay=lambda: 0.0,
|
|
)
|
|
assert callable(middleware)
|