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
406 lines
16 KiB
Python
406 lines
16 KiB
Python
"""Unit tests for :func:`notebooklm._chat.transport.chat_aware_authed_post`.
|
|
|
|
Exercises the chat-domain error-mapping seam over the generic transport
|
|
primitives. Each test injects a stub ``transport`` whose
|
|
``perform_authed_post`` raises one of the transport-layer exceptions
|
|
(or the raw ``httpx`` status error) and asserts the function maps the
|
|
failure to the expected ``ChatError`` / ``NetworkError`` shape, message,
|
|
and exception chain.
|
|
|
|
As of Tier-12 PR 12.5 the drain-tracking bookkeeping
|
|
(``_begin_transport_post`` / ``_finish_transport_post``) has moved into
|
|
``DrainMiddleware`` at the outermost chain position around
|
|
``RuntimeTransport.perform_authed_post``. ``chat_aware_authed_post`` no
|
|
longer brackets its own transport call with explicit drain calls —
|
|
admission and finalization are middleware concerns now. The tests
|
|
correspondingly stub only ``perform_authed_post`` on the transport.
|
|
|
|
As of Wave 8 of the session-decoupling plan (ADR-0014 Rule 2 Corollary),
|
|
``chat_aware_authed_post`` takes the :class:`RuntimeTransport` collaborator
|
|
directly rather than a chat-local ``ChatRuntime`` Protocol, and calls
|
|
``transport.perform_authed_post(build_request=..., log_label=parse_label, ...)``
|
|
on it.
|
|
|
|
The stub ``transport`` is a lightweight ``SimpleNamespace`` rather than a
|
|
``MagicMock(spec=RuntimeTransport)`` so the tests stay independent of the
|
|
class's exact member set — they only need the transport primitive the
|
|
function actually calls. The drain-fires-on-exception invariant is now
|
|
covered by
|
|
``tests/unit/test_drain_middleware.py::test_finish_fires_on_exception``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from notebooklm._chat.transport import chat_aware_authed_post
|
|
from notebooklm._transport_errors import (
|
|
TransportAuthExpired,
|
|
TransportRateLimited,
|
|
TransportServerError,
|
|
)
|
|
from notebooklm.exceptions import ChatError, NetworkError
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test scaffolding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_request() -> httpx.Request:
|
|
return httpx.Request("POST", "https://example.test/x")
|
|
|
|
|
|
def _make_status_error(code: int, *, retry_after: str | None = None) -> httpx.HTTPStatusError:
|
|
headers = {"retry-after": retry_after} if retry_after else {}
|
|
request = _make_request()
|
|
response = httpx.Response(code, request=request, headers=headers)
|
|
return httpx.HTTPStatusError(f"HTTP {code}", request=request, response=response)
|
|
|
|
|
|
def _make_stub_transport(
|
|
*,
|
|
transport_side_effect: Any = None,
|
|
transport_return_value: Any = None,
|
|
) -> SimpleNamespace:
|
|
"""Build a stub ``transport`` matching the slice of ``RuntimeTransport`` we exercise.
|
|
|
|
Pass ``transport_side_effect`` to make ``perform_authed_post`` raise
|
|
(exception instance) or invoke a callable; pass ``transport_return_value``
|
|
to make it return that response unchanged. Exactly one of the two
|
|
should be supplied per test — they are mutually exclusive.
|
|
|
|
PR 12.5 lifted ``_begin_transport_post`` / ``_finish_transport_post``
|
|
into DrainMiddleware, so the stub no longer needs to mock them —
|
|
``chat_aware_authed_post`` does not call them. Wave 8 of
|
|
session-decoupling switched the helper to take a
|
|
:class:`RuntimeTransport` directly, so the stub exposes the
|
|
transport's ``perform_authed_post`` method (the chat-side
|
|
``parse_label`` is forwarded to it as ``log_label``).
|
|
"""
|
|
return SimpleNamespace(
|
|
perform_authed_post=AsyncMock(
|
|
side_effect=transport_side_effect,
|
|
return_value=transport_return_value,
|
|
),
|
|
)
|
|
|
|
|
|
def _noop_build_request(_snapshot: Any) -> tuple[str, str, dict[str, str]]:
|
|
"""Build-request stub: the real transport invokes this, our stub does not."""
|
|
return "https://example.test/x", "payload", {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Happy path — bookkeeping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_aware_authed_post_returns_response_and_balances_bookkeeping():
|
|
"""Success path: response forwarded; begin/finish tokens balanced."""
|
|
expected_response = httpx.Response(200, request=_make_request())
|
|
transport = _make_stub_transport(transport_return_value=expected_response)
|
|
|
|
result = await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
)
|
|
|
|
assert result is expected_response
|
|
transport.perform_authed_post.assert_awaited_once_with(
|
|
build_request=_noop_build_request,
|
|
log_label="chat.ask",
|
|
read_timeout=None,
|
|
max_response_bytes=None,
|
|
disable_read_timeout_retries=False,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_aware_authed_post_forwards_response_cap():
|
|
"""Chat-specific response cap is forwarded to the shared transport."""
|
|
expected_response = httpx.Response(200, request=_make_request())
|
|
transport = _make_stub_transport(transport_return_value=expected_response)
|
|
|
|
result = await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
max_response_bytes=123,
|
|
)
|
|
|
|
assert result is expected_response
|
|
transport.perform_authed_post.assert_awaited_once_with(
|
|
build_request=_noop_build_request,
|
|
log_label="chat.ask",
|
|
read_timeout=None,
|
|
max_response_bytes=123,
|
|
disable_read_timeout_retries=False,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TransportAuthExpired → ChatError
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_auth_expired_maps_to_chat_error():
|
|
original = _make_status_error(401)
|
|
transport_exc = TransportAuthExpired("auth refresh failed", original=original)
|
|
transport = _make_stub_transport(transport_side_effect=transport_exc)
|
|
|
|
with pytest.raises(ChatError) as excinfo:
|
|
await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
)
|
|
|
|
assert "authentication expired" in str(excinfo.value).lower()
|
|
assert "chat.ask" in str(excinfo.value)
|
|
assert excinfo.value.__cause__ is transport_exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TransportRateLimited → ChatError
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_rate_limited_with_retry_after_includes_retry_seconds():
|
|
original = _make_status_error(429, retry_after="42")
|
|
response = original.response
|
|
transport_exc = TransportRateLimited(
|
|
"rate-limited",
|
|
retry_after=42,
|
|
response=response,
|
|
original=original,
|
|
)
|
|
transport = _make_stub_transport(transport_side_effect=transport_exc)
|
|
|
|
with pytest.raises(ChatError) as excinfo:
|
|
await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "rate-limited" in message
|
|
assert "HTTP 429" in message
|
|
assert "Retry after 42 seconds" in message
|
|
assert excinfo.value.__cause__ is transport_exc
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_rate_limited_without_retry_after_omits_retry_clause():
|
|
original = _make_status_error(429)
|
|
response = original.response
|
|
transport_exc = TransportRateLimited(
|
|
"rate-limited",
|
|
retry_after=None,
|
|
response=response,
|
|
original=original,
|
|
)
|
|
transport = _make_stub_transport(transport_side_effect=transport_exc)
|
|
|
|
with pytest.raises(ChatError) as excinfo:
|
|
await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "rate-limited" in message
|
|
assert "HTTP 429" in message
|
|
assert "Retry after" not in message # No "Retry after N seconds" clause.
|
|
assert excinfo.value.__cause__ is transport_exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TransportServerError variants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_server_error_with_http_status_error_maps_to_chat_error():
|
|
original = _make_status_error(503)
|
|
transport_exc = TransportServerError(
|
|
"5xx after retries",
|
|
original=original,
|
|
response=original.response,
|
|
status_code=503,
|
|
)
|
|
transport = _make_stub_transport(transport_side_effect=transport_exc)
|
|
|
|
with pytest.raises(ChatError) as excinfo:
|
|
await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "HTTP 503" in message
|
|
assert "after retries" in message
|
|
assert excinfo.value.__cause__ is transport_exc
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_server_error_with_request_error_maps_to_network_error():
|
|
original = httpx.RequestError("connect failed", request=_make_request())
|
|
transport_exc = TransportServerError("network failure", original=original)
|
|
transport = _make_stub_transport(transport_side_effect=transport_exc)
|
|
|
|
with pytest.raises(NetworkError) as excinfo:
|
|
await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "network error after retries" in message
|
|
assert "timed out" not in message
|
|
assert excinfo.value.original_error is original
|
|
assert excinfo.value.__cause__ is transport_exc
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_server_error_with_read_timeout_includes_chat_stall_diagnostic(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Regression: ``httpx.TimeoutException`` is a subclass of
|
|
``httpx.RequestError``; without the explicit timeout branch the message
|
|
would collapse to the generic "network error after retries" line."""
|
|
original = httpx.ReadTimeout("read timed out", request=_make_request())
|
|
transport_exc = TransportServerError("timeout", original=original)
|
|
transport = _make_stub_transport(transport_side_effect=transport_exc)
|
|
monkeypatch.setenv("NOTEBOOKLM_BL", "boq_labs-tailwind-frontend_test_p0")
|
|
|
|
with pytest.raises(NetworkError) as excinfo:
|
|
await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
read_timeout=30.0,
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "received no streamed chat bytes for 30s" in message
|
|
assert "slow-to-first-byte" in message
|
|
assert "chat-stream stall" in message
|
|
assert "shared notebooks" in message
|
|
assert "NOTEBOOKLM_BL='boq_labs-tailwind-frontend_test_p0'" in message
|
|
assert "--request-timeout 180" in message
|
|
assert "network error after retries" not in message
|
|
assert excinfo.value.original_error is original
|
|
assert excinfo.value.__cause__ is transport_exc
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_server_error_with_blank_read_timeout_message_has_no_stray_colon(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
original = httpx.ReadTimeout("", request=_make_request())
|
|
transport_exc = TransportServerError("timeout", original=original)
|
|
transport = _make_stub_transport(transport_side_effect=transport_exc)
|
|
monkeypatch.setenv("NOTEBOOKLM_BL", "boq_labs-tailwind-frontend_test_p0")
|
|
|
|
with pytest.raises(NetworkError) as excinfo:
|
|
await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
read_timeout=3.0,
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "received no streamed chat bytes for 3s. This points" in message
|
|
assert "for 3s: ." not in message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transport_server_error_with_unexpected_original_type_raises_type_error():
|
|
"""Defensive: the transport layer should only wrap
|
|
``HTTPStatusError`` / ``RequestError`` into ``TransportServerError``.
|
|
Anything else surfaces as ``TypeError`` so an invariant drift is loud
|
|
rather than silently mis-mapped."""
|
|
|
|
class _UnexpectedException(Exception):
|
|
pass
|
|
|
|
original = _UnexpectedException("not http, not request")
|
|
transport_exc = TransportServerError("bogus original", original=original)
|
|
transport = _make_stub_transport(transport_side_effect=transport_exc)
|
|
|
|
with pytest.raises(TypeError) as excinfo:
|
|
await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "TransportServerError.original" in message
|
|
# The diagnostic must include both the actual type and the expected
|
|
# types so a future invariant drift produces an actionable error
|
|
# (per gemini-code-assist review on PR #832).
|
|
assert "Expected httpx.HTTPStatusError or httpx.RequestError" in message
|
|
assert excinfo.value.__cause__ is transport_exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Raw httpx.HTTPStatusError fall-through
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_raw_http_status_error_maps_to_chat_error():
|
|
"""Non-401 / non-429 / non-5xx status errors that fall through
|
|
``RuntimeTransport.perform_authed_post`` reach this layer as raw
|
|
``httpx.HTTPStatusError`` and get wrapped in a ``ChatError`` that
|
|
surfaces the status code."""
|
|
raw_exc = _make_status_error(404)
|
|
transport = _make_stub_transport(transport_side_effect=raw_exc)
|
|
|
|
with pytest.raises(ChatError) as excinfo:
|
|
await chat_aware_authed_post(
|
|
transport, # type: ignore[arg-type]
|
|
build_request=_noop_build_request,
|
|
parse_label="chat.ask",
|
|
)
|
|
|
|
message = str(excinfo.value)
|
|
assert "HTTP 404" in message
|
|
assert "chat.ask" in message
|
|
assert excinfo.value.__cause__ is raw_exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Finalization invariant (PR 12.5: moved into DrainMiddleware)
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# The pre-PR-12.5 contract that ``chat_aware_authed_post`` ran
|
|
# ``_finish_transport_post`` in its own ``finally`` is no longer this
|
|
# function's responsibility — drain admission/finalization moved into
|
|
# ``DrainMiddleware`` at the outermost chain position. The exception-
|
|
# path finalization invariant is now pinned by
|
|
# ``tests/unit/test_drain_middleware.py::test_finish_fires_on_exception``,
|
|
# which exercises a real ``TransportDrainTracker`` end-to-end rather
|
|
# than mocking the bookkeeping.
|
|
#
|
|
# What remains here as a chat-specific invariant: the error-mapping
|
|
# still raises ``ChatError`` even when the underlying transport raises.
|
|
# The error-mapping tests above already cover that path
|
|
# (`test_transport_auth_expired_maps_to_chat_error` exercises the same
|
|
# transport exception used by the deleted finalization test).
|