Files
teng-lin--notebooklm-py/tests/unit/test_auth_refresh_middleware.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

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)